无涯

无所谓无 无所谓有

迭代器模式

1. 引言

在软件开发中,我们经常需要遍历集合或容器对象。传统的遍历方式往往需要暴露集合内部的数据结构,这样会破坏封装性,同时也不够灵活。而迭代器模式能够提供一种统一的遍历接口,使得客户端代码可以独立于集合的具体实现方式进行遍历操作。

2. 痛点例子与解决方案

假设我们有一个存储学生信息的集合,我们希望能够按照先序遍历的方式遍历这个集合,并输出每个学生的姓名。传统的方式可能是通过索引进行遍历,但是这样会暴露集合内部的实现细节,而且不够灵活,不方便扩展。

1
2
3
4
5
6
7
8
复制代码List<String> students = new ArrayList<>();
students.add("Tom");
students.add("Jerry");
students.add("Alice");

for (int i = 0; i < students.size(); i++) {
System.out.println(students.get(i));
}

3. 迭代器模式详解

迭代器模式提供了一种统一的遍历接口,使得客户端代码可以独立于集合的具体实现方式进行遍历操作。它将遍历逻辑封装在迭代器对象中,客户端通过调用迭代器的方法来遍历集合。

迭代器模式包含以下几个角色:

  • 迭代器(Iterator):定义了遍历集合的接口,包含了获取下一个元素、判断是否还有元素等方法。
  • 具体迭代器(ConcreteIterator):实现迭代器接口,负责实现遍历集合的具体逻辑。
  • 集合(Collection):定义了集合对象的接口,包含了获取迭代器的方法。
  • 具体集合(ConcreteCollection):实现集合接口,负责创建具体迭代器对象。

使用迭代器模式重构上述例子,我们可以将遍历逻辑封装在迭代器中,客户端只需要通过调用迭代器的方法就可以按照先序遍历的方式遍历集合,而不需要关心集合的具体实现方式。

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
复制代码import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.NoSuchElementException;

interface PreorderIterator<T> extends Iterator<T> {
boolean hasNext();

T next();
}

interface PreorderCollection<T> extends Iterable<T> {
PreorderIterator<T> iterator();
}

class Student {
private String name;

public Student(String name) {
this.name = name;
}

public String getName() {
return name;
}
}

class StudentCollection implements PreorderCollection<Student> {
private List<Student> students;

public StudentCollection() {
students = new ArrayList<>();
}

public void addStudent(Student student) {
students.add(student);
}

@Override
public PreorderIterator<Student> iterator() {
return new PreorderStudentIterator(students);
}
}

class PreorderStudentIterator implements PreorderIterator<Student> {
private List<Student> students;
private int position;

public PreorderStudentIterator(List<Student> students) {
this.students = students;
position = 0;
}

@Override
public boolean hasNext() {
return position < students.size();
}

@Override
public Student next() {
if (!hasNext()) {
throw new NoSuchElementException();
}

Student student = students.get(position++);
return student;
}
}

public class Main {
public static void main(String[] args) {
StudentCollection studentCollection = new StudentCollection();
studentCollection.addStudent(new Student("Tom"));
studentCollection.addStudent(new Student("Jerry"));
studentCollection.addStudent(new Student("Alice"));

PreorderIterator<Student> iterator = studentCollection.iterator();

while (iterator.hasNext()) {
Student student = iterator.next();
System.out.println(student.getName());
}
}
}

4. 迭代器模式的优劣点

优点:

  • 提供了一种统一的遍历接口,客户端代码可以独立于集合的具体实现方式进行遍历操作。
  • 封装了集合内部的数据结构,提高了代码的灵活性和可维护性。
  • 支持多种遍历方式,可以同时存在多个迭代器。

缺点:

  • 增加了代码的复杂性,需要额外的迭代器对象。
  • 对于一些简单的集合,使用迭代器模式可能会过于繁琐。

5. 二叉树迭代器与foreach遍历

在现实中,二叉树是一种常见的数据结构。我们可以实现一个二叉树迭代器,使得客户端代码可以使用foreach语法糖来按照先序遍历的方式遍历二叉树。

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
复制代码class TreeNode {
int val;
TreeNode left;
TreeNode right;

public TreeNode(int val) {
this.val = val;
}
}

class BinaryTree implements PreorderCollection<Integer> {
private TreeNode root;

public BinaryTree(TreeNode root) {
this.root = root;
}

@Override
public PreorderIterator<Integer> iterator() {
return new PreorderTreeIterator(root);
}
}

class PreorderTreeIterator implements PreorderIterator<Integer> {
private Stack<TreeNode> stack;

public PreorderTreeIterator(TreeNode root) {
stack = new Stack<>();
if (root != null) {
stack.push(root);
}
}

@Override
public boolean hasNext() {
return !stack.isEmpty();
}

@Override
public Integer next() {
if (!hasNext()) {
throw new NoSuchElementException();
}

TreeNode node = stack.pop();
if (node.right != null) {
stack.push(node.right);
}
if (node.left != null) {
stack.push(node.left);
}
return node.val;
}
}

public class Main {
public static void main(String[] args) {
TreeNode root = new TreeNode(1);
root.left = new TreeNode(2);
root.right = new TreeNode(3);
root.left.left = new TreeNode(4);
root.left.right = new TreeNode(5);
root.right.left = new TreeNode(6);
root.right.right = new TreeNode(7);

BinaryTree binaryTree = new BinaryTree(root);

for (int val : binaryTree) {
System.out.print(val + " ");
}
}
}

结束语

本文详细介绍了迭代器模式的原理和应用,并通过一个痛点例子引出了迭代器模式的解决方案。同时,还提供了现实中二叉树的迭代器示例,并展示了如何使用foreach语法糖来按照先序遍历的方式遍历二叉树。迭代器模式提供了一种灵活且封装的遍历方式,可以提高代码的可维护性和可扩展性。希望本文能够帮助您理解迭代器模式的原理和应用。

至此,本文介绍了迭代器模式的原理、解决方案以及优劣点,并提供了现实中二叉树的迭代器示例。

原理

采用完全基于 channel+select 的实现方案,不使用其他数据结 构,也不使用 sync 包提供的各种同步结构,比如 Mutex、RWMutex,以及 Cond 等。

线程池的实现主要分为 三个部分:

  1. pool的创建与销毁

  2. pool 中worker(Goroutine)的管理

  3. task 的提交与调度

    其中,后两部分是 pool 的“精髓”所在,这两部分的原理我也用一张图表示了出来:

我们先看一下图中 pool 对 worker 的管理。

capacity 是 pool 的一个属性,代表整个 pool 中 worker 的最大容量。我们使用一个带缓 冲的 channel:active,作为 worker 的“计数器”

当 active channel 可写时,我们就创建一个 worker,用于处理用户通过 Schedule 函数 提交的待处理的请求。当 active channel 满了的时候,pool 就会停止 worker 的创建,直 到某个 worker 因故退出,active channel 又空出一个位置时,pool 才会创建新的 worker 填补那个空位。

这张图里,我们把用户要提交给 workerpool 执行的请求抽象为一个 Task。Task 的提交 与调度也很简单:Task 通过 Schedule 函数提交到一个 task channel 中,已经创建的 worker 将从这个 task channel 中读取 task 并执行。

好了!“Talk is cheap,show me the code”!接下来,我们就来写一版代码,来验证一下这里分析的原理是否可行。

线程池的一个最小可行实现

创建一个包,命名为workerpool

定义 Pool 结构体类型,这个类型的实例代表一个workerpool

1
2
3
4
5
6
7
8
// Pool is a worker pool.
type Pool struct {
capacity int // max number of workers
active chan struct{} // active workers
tasks chan Task // tasks to be executed
wg sync.WaitGroup // wait group for all workers
quit chan struct{} // quit signal
}

workerpool 包对外主要提供三个 API,它们分别是:

  • workerpool.New: 用于创建一个 pool 类型实例,并将 pool 池的 worker 管理机制运 行起来;

  • workerpool.Free: 用于销毁一个 pool 池,停掉所有 pool 池中的 worker;

  • Pool.Schedule: 这是 Pool 类型的一个导出方法,workerpool 包的用户通过该方法向 pool 池提交待执行的任务(Task)。

接下来我们就重点看看这三个 API 的实现。

我们先来看看 workerpool.New 是如何创建一个 pool 实例的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// New creates a new worker pool with default capacity.
func New(capacity int) *Pool {
if capacity <= 0 {
capacity = defaultCapacity
}
if capacity > maxCapacity {
capacity = maxCapacity
}

p := &Pool{
capacity: capacity,
active: make(chan struct{}, capacity),
tasks: make(chan Task),
quit: make(chan struct{}),
}
fmt.Println("worker pool start")
go p.run()
return p
}

我们看到,New 函数接受一个参数 capacity 用于指定 workerpool 池的容量,这个参数 用于控制 workerpool 最多只能有 capacity 个 worker,共同处理用户提交的任务请求。 函数开始处有一个对 capacity 参数的“防御性”校验,当用户传入不合理的值时,函数 New 会将它纠正为合理的值。

Pool 类型实例变量 p 完成初始化后,我们创建了一个新的 Goroutine,用于对 workerpool 进行管理,这个 Goroutine 执行的是 Pool 类型的 run 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// run the worker pool.
func (p *Pool) run() {
idx := 0
for {
select {
case <-p.quit:
fmt.Println("worker pool stop")
return
case p.active <- struct{}{}: //create new worker until the capacity is reached
idx++
p.newWorker(idx)
}
}
}

run 方法内是一个无限循环,循环体中使用 select 监视 Pool 类型实例的两个 channel: quit 和 active。这种在 for 中使用 select 监视多个 channel 的实现,在 Go 代码中十分 常见,是一种惯用法。

当接收到来自 quit channel 的退出“信号”时,这个 Goroutine 就会结束运行。而当 active channel 可写时,run 方法就会创建一个新的 worker Goroutine。 此外,为了方 便在程序中区分各个 worker 输出的日志,我这里将一个从 1 开始的变量 idx 作为 worker 的编号,并把它以参数的形式传给创建 worker 的方法。

我们再将创建新的 worker goroutine 的职责,封装到一个名为 newWorker 的方法中:

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
// newWorker creates a new worker.
func (p *Pool) newWorker(idx int) {
p.wg.Add(1)
go func() {
defer func() {
if err := recover(); err != nil {
fmt.Printf("worker[%03d]: recover panic[%s] and exit\n", idx, err)
<-p.active
}
p.wg.Done()
}()
fmt.Printf("worker[%03d]: start\n", idx)
for {
select {
case <-p.quit:
fmt.Printf("worker[%03d]: exit\n", idx)
<-p.active
return
case task := <-p.tasks:
fmt.Printf("worker[%03d]: receive a task and ready to run\n", idx)
task()
fmt.Printf("worker[%03d]: task done\n", idx)
}
}
}()
}

我们看到,在创建一个新的 worker goroutine 之前,newWorker 方法会先调用 p.wg.Add 方法将 WaitGroup 的等待计数加一。由于每个 worker 运行于一个独立的Goroutine 中,newWorker 方法通过 go 关键字创建了一个新的 Goroutine 作为 worker。

新 worker 的核心,依然是一个基于 for-select 模式的循环语句,在循环体中,新 worker 通过 select 监视 quit 和 tasks 两个 channel。和前面的 run 方法一样,当接收到来自 quit channel 的退出“信号”时,这个 worker 就会结束运行。tasks channel 中放置的 是用户通过 Schedule 方法提交的请求,新 worker 会从这个 channel 中获取最新的 Task 并运行这个 Task。

Task 是一个对用户提交的请求的抽象,它的本质就是一个函数类型:

1
2
// Task is the task to be executed.
type Task func()

这样,用户通过 Schedule 方法实际上提交的是一个函数类型的实例。

在新 worker 中,为了防止用户提交的 task 抛出 panic,进而导致整个 workerpool 受到 影响,我们在 worker 代码的开始处,使用了 defer+recover 对 panic 进行捕捉,捕捉后 worker 也是要退出的,于是我们还通过<-p.active更新了 worker 计数器。并且一旦 worker goroutine 退出,p.wg.Done 也需要被调用,这样可以减少 WaitGroup 的 Goroutine 等待数量。

我们再来看 workerpool 提供给用户提交请求的导出方法 Schedule:

1
2
3
4
5
6
7
8
9
// Schedule submits a task to the worker pool.
func (p *Pool) Schedule(task Task) error {
select {
case <-p.quit:
return ErrWorkerPoolFreed
case p.tasks <- task:
return nil
}
}

Schedule 方法的核心逻辑,是将传入的 Task 实例发送到 workerpool 的 tasks channel 中。但考虑到现在 workerpool 已经被销毁的状态,我们这里通过一个 select,检视 quit channel 是否有“信号”可读,如果有,就返回一个哨兵错误 ErrWorkerPoolFreed。如 果没有,一旦 p.tasks 可写,提交的 Task 就会被写入 tasks channel,以供 pool 中的 worker 处理。

这里要注意的是,这里的 Pool 结构体中的 tasks 是一个无缓冲的 channel,如果 pool 中 worker 数量已达上限,而且 worker 都在处理 task 的状态,那么 Schedule 方法就会阻 塞,直到有 worker 变为 idle 状态来读取 tasks channel,schedule 的调用阻塞才会解 除。

我们再来看看如何关闭线程池:

1
2
3
4
5
6
7
// Free closes the worker pool.
func (p *Pool) Free() {
close(p.quit) // 发送退出信号
p.wg.Wait() // 等待所有worker退出
close(p.tasks) // 关闭任务队列
close(p.active) // 关闭活跃队列
}

至此,workerpool 的最小可行实现的主要逻辑都实现完了。我们来验证一下它是否能按照 我们的预期逻辑运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func main() {
pool := workerpool.New(5)
time.Sleep(10 * time.Second)
for i := 0; i < 10; i++ {
j := i // 拷贝 i 到 j ,以便于在 goroutine 中使用
err := pool.Schedule(func() {
fmt.Printf("this is a task %d, running ...................\n", j)
time.Sleep(3 * time.Second)
fmt.Printf("this is a task %d, done ......................\n", j)
})
if err != nil {
fmt.Println("task submit failed:", err)
}
}
pool.Free()
}

这个示例程序创建了一个 capacity 为 5 的 workerpool 实例,并连续向这个 workerpool 提交了 10 个 task,每个 task 的逻辑很简单,只是 Sleep 3 秒后就退出。main 函数在提 交完任务后,调用 workerpool 的 Free 方法销毁 pool,pool 会等待所有 worker 执行完 task 后再退出。

运行结果如下:

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
worker pool start
worker[003]: start
worker[002]: start
worker[005]: start
worker[004]: start
worker[001]: start
worker[001]: receive a task and ready to run
this is a task 4, running ...................
worker[004]: receive a task and ready to run
this is a task 3, running ...................
worker[002]: receive a task and ready to run
worker[003]: receive a task and ready to run
this is a task 1, running ...................
worker[005]: receive a task and ready to run
this is a task 2, running ...................
this is a task 0, running ...................
this is a task 0, done ......................
worker[003]: task done
this is a task 2, done ......................
worker[005]: task done
this is a task 1, done ......................
worker[002]: task done
worker[002]: receive a task and ready to run
this is a task 7, running ...................
this is a task 4, done ......................
worker[003]: receive a task and ready to run
worker[001]: task done
worker[001]: receive a task and ready to run
this is a task 8, running ...................
worker[005]: receive a task and ready to run
this is a task 6, running ...................
this is a task 3, done ......................
worker[004]: task done
this is a task 5, running ...................
worker pool stop
worker[004]: receive a task and ready to run
this is a task 9, running ...................
this is a task 6, done ......................
this is a task 7, done ......................
worker[002]: task done
worker[002]: exit
this is a task 8, done ......................
worker[001]: task done
worker[001]: exit
this is a task 5, done ......................
worker[003]: task done
this is a task 9, done ......................
worker[004]: task done
worker[004]: exit
worker[005]: task done
worker[005]: exit
worker[003]: exit

从运行的输出结果来看,workerpool 的最小可行实现的运行逻辑与我们的原理图是一致的。

选择Go版本

挑版本之前,我们先来看看 Go 语言的版本发布策略

如今,Go 团队已经将版本发布节奏稳定在每年发布两次大版本上,一般是在二月份和八月份发布。Go 团队承诺对最新的两个 Go 稳定大版本提供支持,比如目前最新的大版本是 Go 1.17,那么 Go 团队就会为 Go 1.17 和 Go 1.16 版本提供支持。如果 Go 1.18 版本发布,那支持的版本将变成 Go 1.18 和 Go 1.17。支持的范围主要包括修复版本中存在的重大问题、文档变更以及安全问题更新等。

基于这样的版本发布策略,在你选择版本时可以参考这几种思路:

一般情况下,我建议你采用最新版本。因为 Go 团队发布的 Go 语言稳定版本的平均质量一直是很高的,少有影响使用的重大 bug。你也不用太担心新版本的支持问题,Google 的自有产品,比如 Google App Engine(以下简称为 GAE)支持都会很快,一般在 Go 新版本发布不久后,GAE 便会宣布支持最新版本的 Go。

你还可以根据不同实际项目需要或开源社区的情况使用不同的版本。

有的开源项目采纳了 Go 团队的建议,在 Go 最新版本发布不久就将当前项目的 Go 编译器版本升级到最新版,比如 Kubernetes 项目;而有的开源项目(比如:docker 项目)则比较谨慎,在 Go 团队发布 Go 1.17 版本之后,这些项目可能还在使用两个发布周期之前的版本,比如 Go 1.15。

但多数项目处于两者之间,也就是使用次新版,即最新版本之前的那个版本。比如,当前最新版本为 Go 1.17,那么这些项目会使用 Go 1.16 版本的最新补丁版本(Go 1.16.x),直到发布 Go 1.18 后,这些项目才会切换到 Go 1.17 的最新补丁版本(Go 1.17.x)。如果你不是那么“激进”,也可以采用这种版本选择策略。

建议你直接使用 Go 最新发布版,这样我们可以体验到 Go 的最新语言特性,应用到标准库的最新 API 以及 Go 工具链的最新功能。

选定 Go 版本后,接下来我们就来看看几种常见的 Go 安装方法。

安装Go

Go 从 2009 年开源并演化到今天,它的安装方法其实都已经很成熟了,接下来呢,我们就逐一介绍在 Linux、macOS、Windows 这三大主流操作系统上安装 Go 的方法,我们假设这些操作系统都安装在 X86-64 的平台上,首先我们来看 Linux。

在 Mac 上安装 Go

在 Mac 上我们可以在图形界面的引导下进行 Go 的安装。不过,我们先要下载适用于 Mac 的 Go 安装包:

1
wget -c https://golang.google.cn/dl/go1.16.5.darwin-amd64.pkg

安装包下载完毕后,我们可以双击安装包,打开标准的 Mac 软件安装界面,如下图所示:

按软件安装向导提示,一路点击“继续”,我们便可以完成 Go 在 Mac 上的安装。

Mac 上的 Go 安装包默认会将 Go 安装到 /usr/local/go 路径下面。因此,如果要在任意路径下使用 Go,我们也需将这个路径加入到用户的环境变量 PATH 中。具体操作方法与上面 Linux 中的步骤一样,也是将下面环境变量设置语句加入到 $HOME/.profile 中,然后执行 source 命令让它生效就可以了:

1
export PATH=$PATH:/usr/local/go/bin

最后,我们可以通过 go version 命令验证一下这次安装是否成功。

在 Windows 上安装 Go

在 Windows 上,我们最好的安装方式就是采用图形界面引导下的 Go 安装方法。

我们打开Go 包的下载页面,在页面上找到 Go 1.16.5 版本的 Windows msi 安装包(AMD64 架构下的):go1.16.5.windows-amd64.msi,通过浏览器自带的下载工具它下载到本地任意目录下。

和所有使用图形界面方式安装的 Windows 应用程序一样,我们只需一路点击“继续(next)”就可完成 Go 程序的安装了,安装程序默认会把 Go 安装在 C:\Program Files\Go 下面,当然你也可以自己定制你的安装目录。

除了会将 Go 安装到你的系统中之外,Go 安装程序还会自动为你设置好 Go 使用所需的环境变量,包括在用户环境变量中增加 GOPATH,它的值默认为 C:\Users[用户名]\go,在系统变量中也会为 Path 变量增加一个值:C:\Program Files\Go\bin,这样我们就可以在任意路径下使用 Go 了。

安装完成后,我们可以打开Windows的“命令提示符”窗口(也就是CMD命令)来验证一下Go有没有安装成功。在命令行中输入go version,如果看到下面这个输出结果,那证明你这次安装就成功了:

1
2
C:\Users\oneyoung>go version
go version go1.16.5 windows/amd64

Go 语言是怎样诞生的?

Go 语言的创始人有三位,分别是图灵奖获得者、C 语法联合发明人、Unix 之父肯·汤普森(Ken Thompson),Plan 9 操作系统领导者、UTF-8 编码的最初设计者罗伯·派克(Rob Pike),以及 Java 的 HotSpot 虚拟机和 Chrome 浏览器的 JavaScript V8 引擎的设计者之一罗伯特·格瑞史莫(Robert Griesemer)。

他们可能都没有想到,他们三个人在 2007 年 9 月 20 日下午的一次普通讨论,就这么成为了计算机编程语言领域的一次著名历史事件,开启了一个新编程语言的历史。

img

那天下午,在谷歌山景城总部的那间办公室里,罗伯·派克启动了一个 C++ 工程的编译构建。按照以往的经验判断,这次构建大约需要一个小时。利用这段时间,罗伯·派克和罗伯特·格瑞史莫、肯·汤普森坐在一处,交换了关于设计一门新编程语言的想法。

之所以有这种想法,是因为当时的谷歌内部主要使用 C++ 语言构建各种系统,但 C++ 的巨大复杂性、编译构建速度慢以及在编写服务端程序时对并发支持的不足,让三位大佬觉得十分不便,他们就想着设计一门新的语言。在他们的初步构想中,这门新语言应该是能够给程序员带来快乐、匹配未来硬件发展趋势并适合用来开发谷歌内部大规模网络服务程序的。

趁热打铁!在第一天的简短讨论后,第二天这三位大佬又在谷歌总部的“雅温得(Yaounde)”会议室里具体讨论了这门新语言的设计。会后罗伯特·格瑞史莫发出了一封题为“prog lang discussion”的电邮,对这门新编程语言的功能特性做了初步的归纳总结:

img

这封电邮对这门新编程语言的功能特性做了归纳总结。主要思路是,在 C 语言的基础上,修正一些明显的缺陷,删除一些被诟病较多的特性,增加一些缺失的功能,比如,使用 import 替代 include、去掉宏、增加垃圾回收、支持接口等。这封电邮成为了这门新语言的第一版特性设计稿,三位大佬在这门语言的一些基础语法特性上达成了初步一致。

9 月 25 日,罗伯·派克在一封回复电邮中把这门新编程语言命名为“go”:

img

在罗伯·派克的心目中,“go”这个单词短小、容易输入并且在组合其他字母后便可以用来命名 Go 相关的工具,比如编译器(goc)、汇编器(goa)、链接器(gol)等(go 的早期版本曾如此命名 go 工具链,但后续版本撤销了这种命名方式,仅保留 go 这一统一的工具链名称 )。

这里我还想澄清一个误区,很多 Go 语言初学者经常称这门语言为 Golang,其实这是不对的:“Golang”仅应用于命名 Go 语言官方网站,而且当时没有用 go.com 纯粹是这个域名被占用了而已。

从“三人行”到“众人拾柴”

经过早期讨论,Go 语言的三位作者在语言设计上达成初步一致后,便开启了 Go 语言迭代设计和实现的过程。

2008 年初,Unix 之父肯·汤普森实现了第一版 Go 编译器,用于验证之前的设计。这个编译器先将 Go 代码转换为 C 代码,再由 C 编译器编译成二进制文件。

到 2008 年年中,Go 的第一版设计就基本结束了。这时,同样在谷歌工作的伊恩·泰勒(Ian Lance Taylor)为 Go 语言实现了一个 gcc 的前端,这也是 Go 语言的第二个编译器。

伊恩·泰勒的这一成果不仅仅是一种鼓励,也证明了 Go 这一新语言的可行性 。有了语言的第二个实现,对 Go 的语言规范和标准库的建立也是很重要的。随后,伊恩·泰勒以团队的第四位成员的身份正式加入 Go 语言开发团队,后面也成为了 Go 语言,以及其工具设计和实现的核心人物之一。

罗斯·考克斯(Russ Cox)是 Go 核心开发团队的第五位成员,也是在 2008 年加入的。进入团队后,罗斯·考克斯利用函数类型是“一等公民”,而且它也可以拥有自己的方法这个特性巧妙设计出了 http 包的HandlerFunc类型。这样,我们通过显式转型就可以让一个普通函数成为满足http.Handler接口的类型了。

不仅如此,罗斯·考克斯还在当时设计的基础上提出了一些更泛化的想法,比如io.Reader和io.Writer接口,这就奠定了 Go 语言的 I/O 结构模型。后来,罗斯·考克斯成为 Go 核心技术团队的负责人,推动 Go 语言的持续演化。

到这里,Go 语言最初的核心团队形成,Go 语言迈上了稳定演化的道路。

2009 年 10 月 30 日,罗伯·派克在 Google Techtalk 上做了一次有关 Go 语言的演讲“The Go Programming Language”,这也是 Go 语言第一次公之于众。十天后,也就是 2009 年 11 月 10 日,谷歌官方宣布 Go 语言项目开源,之后这一天也被 Go 官方确定为 Go 语言的诞生日。

img

在 Go 语言项目开源后,Go 语言也迎来了自己的“吉祥物”,是一只由罗伯·派克夫人芮妮·弗伦奇(Renee French)设计的地鼠,从此地鼠(gopher)也就成为了世界各地 Go 程序员的象征,Go 程序员也被昵称为 Gopher,在后面的课程中,我会直接使用 Gopher 指代 Go 语言开发者。

img

Go 语言项目的开源使得 Go 语言吸引了全世界开发者的目光,再加上 Go 三位作者在业界的影响力以及谷歌这座大树的加持,更多有才华的程序员加入到 Go 核心开发团队中,更多贡献者开始为 Go 语言项目添砖加瓦。于是,Go 在宣布开源的当年,也就是 2009 年,就成为了著名编程语言排行榜 TIOBE 的年度最佳编程语言。

2012 年 3 月 28 日,Go 1.0 版本正式发布,同时 Go 官方发布了“Go 1 兼容性”承诺:只要符合 Go 1 语言规范的源代码,Go 编译器将保证向后兼容(backwards compatible),也就是说我们使用新版编译器也可以正确编译用老版本语法编写的代码。

img

从此,Go 语言发展得非常迅猛。从正式开源到现在,十一年的时间过去了,Go 语言发布了多个大版本更新,逐渐成熟。这里,我也梳理了迄今为止 Go 语言的重大版本更新,希望能帮助你快速了解 Go 语言的演化历史。

img

Go 是否值得我们学习?

时间已经来到了 2021 年。经过了十余年的打磨与优化,如今的 Go 语言已经逐渐成为了云计算时代基础设施的编程语言。你能想到的现代云计算基础设施软件的大部分流行和可靠的作品,都是用 Go 编写的,比如:Docker、Kubernetes、Prometheus、Ethereum(以太坊)、Istio、CockroachDB、InfluxDB、Terraform、Etcd、Consul 等等。当然,这个列表还在持续增加,可见 Go 语言的影响力已经十分强大。

Go 除了在云计算基础设施领域,拥有上面这些杀手级应用之外,Go 语言的用户数量也在近几年快速增加。Go 语言项目技术负责人罗斯·考克斯甚至还专门写过一篇文章,来估算全世界范围的 Gopher 数量。按照他的估算结果,全世界范围的 Gopher 数量从 2017 年年中的最多 100 万,增长到 2019 年 11 月的最多 196 万,大概两年半翻了一番。庞大的 Gopher 基数为 Go 未来的发展提供持续的增长潜力和更大的想象空间。

那么 Go 语言前景究竟如何,值不值得投入去学习呢?

我在想,是否存在一种成熟的方法,能相对客观地描绘出 Go 语言的历史发展趋势,并对未来 Go 的走势做出指导呢?我想来想去,觉得 Gartner 的技术成熟度曲线(The Hype Cycle)可以借来一试。

Gartner 的技术成熟度曲线又叫技术循环曲线,是企业用来评估新科技是否要采用或采用时机的一种可视化方法,它利用时间轴与该技术在市面上的可见度(媒体曝光度)决定要不要采用,以及什么时候采用这种新科技,下面就是一条典型的技术成熟度曲线的形状:

img

同理,如果我们将这条技术成熟度曲线应用于某种编程语言,比如 Go,我们就可以用它来判断这门编程语言所处的成熟阶段,来辅助我们决定要不要采用,以及何时采用这门语言。

我们从知名的 TIOBE 编程语言指数排行榜获取 Go 从 2009 年开源以来至今的指数曲线图,并且根据 Go 版本发布历史在图中标记出了各个时段的 Go 发布版本,你可以看看。

img

对比前面的 Gartner 成熟度曲线,我们可以得出这样的结论:Go 在经历了一个漫长的技术萌芽期后,从实现自举的 Go 1.5 版本开始逐步进入“期望膨胀期”,在经历从 Go 1.6 到 Go 1.9 版本的发布后,业界对 Go 的期望达到了峰值。

但随后“泡沫破裂”,在 Go 1.11 发布前跌到了“泡沫破裂期”的谷底,Go 1.11 版本引入了 Go module,给社区解决 Go 包依赖问题注射了一支强心剂,于是 Go 又开始了缓慢爬升。

从 TIOBE 提供的曲线来看,Go 1.12 到 Go 1.15 版本的发布让我们有信心认为 Go 已经走出“泡沫破裂谷底期”,进入到“稳步爬升的光明期”。

至于 Go 什么时候能达到实质生产高峰期呢?

我们还不好预测,但这应该是一个确定性事件。我认为现在离它到达实质生产高峰期只是一个时间问题了。也许预计在 2022 年初发布的支持 Go 泛型特性的 Go 1.18 版本,会是继 Go 1.5 版本之后又一“爆款”,很可能会快速推动 Go 迈入更高的发展阶段。

小结

我前面也说了,一门编程语言的历史和现状,能给你带来学习的“安全感”,相信它可以提升你的个人价值,也会让你获得丰厚的回报。你也会更加清楚地认识到:自己为什么要学习它?它未来的发展趋势又是怎样的?而且,当这门语言的现状能给予你极大“安全感”的时候,我们才会“死心塌地”地学习和钻研这门语言,而不会有太多的后顾之忧。

从 Go 本身的发展来看,和多数编程语言一样,Go 语言在诞生后,度过了一个较长的“技术萌芽期”。然后,实现了自举,而且对 GC 延迟进行了大幅优化的 Go 1.5 版本,成为了 Go 语言演化过程中的第一个“引爆点”,推动 Go 语言进入“技术膨胀期”。

也正是在这段时间内,Go 语言以迅雷不及掩耳盗铃之势推出了以 Docker、Kubernetes 为典型代表的“杀手级应用”,充分展现了实力,在世界范围收获了百万粉丝,迸发出极高的潜力和持续的活力。

Go 开源于 2009 年末,如果从那时算起,Go 才 11 岁。但在 Go 核心开发团队眼中,Go 的真正诞生年份是 2007 年,距今已 13 个年头有余了。

回顾一下计算机编程语言的历史,我们会发现,绝大多数主流编程语言,都将在其 15 至 20 年间大步前进。Java、Python、Ruby、JavaScript 和许多其他编程语言都是这样。如今 Go 语言也马上进入自己的黄金 5~10 年,从前面的技术成熟度曲线分析也可以印证这一点:Go 已经重新回到“稳步爬升的光明期”。

前言

在多层应用中,不管是之前的经典三层模型、还是现在非常流行的DDD模型,都需要对各种不同的分层对象(JavaBean)进行转换,也就是JavaBean的属性映射问题。最常见和最简单的方式是编写对象属性转换函数,即普通的 Getter/Setter 方法。除此之外各种各种属性映射工具。

几种常见的 Java 属性映射工具及原理

工具

  1. org.springframework.beans.BeanUtils#copyProperties
  2. org.apache.commons.beanutils.BeanUtils#copyProperties
  3. net.sf.cglib.beans.BeanCopier#copy
  4. mapstruct

原理

反射技术

Spring框架的BeanUtilsAppachBeanUtils都是使用的Java反射技术实现映射。效率相对较差。

字节码生成技术

cglibBeanCopier使用的是动态生成字节码的技术,动态生成对象间的转换器,与手动写gettersetter性能相当。

编译期技术

类似Lombokmapstruct其实也是一种代码生成技术,在编译期生成真实的转换代码,所以性能也与手动写gettersetter性能相当。

因此从性能来讲首推 Getter/Setter 方式(含 MapStruct),其次是 cglib

选用哪种技术?

属性转换工具的优势:用起来方便,往往一行行代码就实现多属性的转换。

mapstruct在属性不对应的情况下,还可以通过注解或者修改配置方式自动适配,功能非常强大。

属性转换工具的缺点

  1. 多次对象映射(从 A 映射到 B,再从 B 映射到 C)如果属性不完全一致容易出错;
  2. 有些转换工具,属性类型不一致自动转换容易出现意想不到的 BUG;
  3. 基于反射和字节码增强技术的映射工具实现的映射,对一个类属性的修改不容易感知到对其它转换类的影响。

我们可以想想这样一个场景

一个UserDO如果属性超多,转换到UserDTO再被转换成UserVO。如果你修改UserDTO的一个属性命名,其它类待映射的类新增的对应属性有一个字母写错了,编译期间不容易发现问题,造成 BUG。

如果使用原始的 Getter/Setter 方式转换,修改了UserDO的属性,那么转换代码就会报错,编译都不通过,这样就可以逆向提醒我们注意到属性的变动的影响。

因此强烈建议使用定义转换类和转换函数,使用插件实现转换,不需要引入其它库,降低了复杂性,可以支持更灵活的映射。

大家可以想想这种场景:

如果一个 A 映射到 B,B 有两个属性来自 C,一个属性来自于传参或者计算等。

此时自定义转换函数就更方便。

如果使用属性映射工具推荐使用 MapStruct,更安全一些,转换效率也很高。

但是MapStruct也有他的缺点,对代码有一定的侵入性。及时两个对象拥有类型、名称相同属性,每两个转换对象需要定义Mapper

oneyoung-converter

oneyoung-converter结合了两种主流的映射方案。使用方式简单方便,开箱即用。

  • 支持自动映射,像BeanUtils一样,只需要传入源对象(Object)和目标对象(Class),即可完成转换。
    • 底层使用了Java动态编译技术,自动生成转换器代码,编译并载入JVM
    • 两个对象间,自动转换器只会生成一次,然后就会装入Cache复用
    • 性能与自定义Getter/Setter一致。
  • 支持自定义映射,你可以自定义转换器,只需要实现它提供的接口top.oneyoung.converter.Converter,并注册到转换器工厂。ConverterFactory.register(new CreateSchoolRequestToCreateSchoolServiceRequestConverter());
  • 提供自动映射启停开关,若开启自动映射且添加了自定义映射,将优先使用自定义的转换器完成映射。若关闭了自动映射且没有自定义转换器,则会抛出找不到转换器的异常ConvertException.

不管你是自动映射还是自定义映射,oneyoung-converter的使用方式只有下面这几种。

  1. 直接转换两个对象
1
2
CreateSchoolRequest request = CreateSchoolRequest.builder().name("test").build();
CreateSchoolServiceRequest createSchoolServiceRequest = Converter.directConvert(request, CreateSchoolServiceRequest.class);
  1. 转换集合对象
1
2
3
4
5
6
7
8
9
10
CreateSchoolRequest input = CreateSchoolRequest.builder()
.name("test1")
.address("test address1")
.build();
CreateSchoolRequest input1 = CreateSchoolRequest.builder()
.name("test2")
.address("test address2")
.build();
List<CreateSchoolRequest> createSchoolRequests = Arrays.asList(input, input1);
List<CreateSchoolServiceRequest> createSchoolServiceRequests = Converter.directConvertCollectionSingle(createSchoolRequests, CreateSchoolServiceRequest.class);

oneyoung-converter集成spring-boot

引入依赖

依赖已发布至Maven中央仓库

https://repo1.maven.org/maven2/top/oneyoung/

1
2
3
4
5
<dependency>
<groupId>top.oneyoung</groupId>
<artifactId>oneyoung-converter-starter</artifactId>
<version>1.0.0</version>
</dependency>

配置

1
2
# 控制自动映射开闭
oneyoung.converter.auto-convert=true

添加自定义映射

创建一个自定义转换器,只需要实现接口top.oneyoung.converter.Converter,并注册为spring bean,即会载入转换器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Slf4j
@Component
public class RequestToResponseConverter implements Converter<Request, Response> {

@Override
public Response convert(Request source) {
if (source == null) {
return null;
}
log.info("RequestToResponseConverter.convert: {}", source);
Response target = new Response();
target.setName(source.getName());
return target;
}
}

spring在加载初期就会自动扫描需要加载的bean,通过实现BeanPostProcessor我们做了拦截,将Converter载入ConverterFactory

看到日志下面这种日志,说明我们成功注册了自定义转换器。

1
2022-04-16 12:26:56.584  INFO 75693 --- [           main] t.o.c.starter.SpringFactoryRegister      : ConverterFactory Register: class top.oneyoung.portal.entity.Request-->class top.oneyoung.portal.entity.Response By class top.oneyoung.portal.converter.RequestToResponseConverter

使用

与上面列举的使用方式一致。

开源

这个转换器已经被我开源。欢迎Star https://github.com/oneyoungg/oneyoung

前言

我们都知道Java属于编译型语言,即源码需要经过编译成字节码然后运行于JVM

我们也知道,代码一旦编写完成,编译出的.class文件是一定的。这里也就是静态编译

那我们需要在运行时编译并加载应该怎么办呢,存在如下场景

  • 我们熟知的类似LeetCode这种测评平台,需要执行用户输入的代码。
  • 服务器需要动态加载某些类文件进行编译。

那么我们就要使用Java动态编译能力,在运行时编译代码并加载进jvm

原理

Java 6开始,引入了Java代码重写过的编译器接口,使得我们可以在运行时编译Java源代码,然后再通过类加载器将编译好的类加载进JVM,这种在运行时编译代码的操作就叫做动态编译

主要类库

  • JavaCompiler - 表示java编译器, run方法执行编译操作. 还有一种编译方式是先生成编译任务(CompilationTask), 让后调用CompilationTaskcall方法执行编译任务

  • JavaFileObject - 表示一个java源文件对象

  • JavaFileManager - Java源文件管理类, 管理一系列JavaFileObject

  • Diagnostic - 表示一个诊断信息

  • DiagnosticListener - 诊断信息监听器, 编译过程触发. 生成编译task(JavaCompiler#getTask())或获取FileManager(JavaCompiler#getStandardFileManager())时需要传递DiagnosticListener以便收集诊断信息

流程图

未命名2.001

源码文件 -> 字节码文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void fromJavaFile() {
// 获取Javac编译器对象
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
// 获取文件管理器:负责管理类文件的输入输出
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
// 获取要被编译的Java源文件
File file = new File("/Users/oneyoung/oneyoung/project/my/code/src/main/java/top/oneyoung/dynamic/TestHello.java");
// 通过源文件获取到要编译的Java类源码迭代器,包括所有内部类,其中每个类都是一个JavaFileObject
Iterable<? extends JavaFileObject> compilationUnits = fileManager.getJavaFileObjects(file);
// 生成编译任务
JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, null, null, null, compilationUnits);
// 执行编译任务
task.call();
}

我们这里准备了TestHello.java

1
2
3
4
5
public class TestHello {
public static void main(String[] args) {
System.out.println("this is a test");
}
}

执行后在源文件同目录下生成了编译的class文件HelloTest.class

image-20220412152021665

我们试着手动加载该class文件,使用类加载器的defineClass方法,可以直接加载字节码文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static Class<?> loadClassFromDisk(String path) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
// defineClass 为 ClassLoader 类的一个方法,用于加载类
// 但是这个方法是 protected 的,所以需要通过反射的方式获取这个方法的权限
Class<ClassLoader> classLoaderClass = ClassLoader.class;
Method defineClass = classLoaderClass.getDeclaredMethod("defineClass", byte[].class, int.class, int.class);
defineClass.setAccessible(true);
// 读取文件系统的 file 为 byte 数组
File file = new File(path);
byte[] bytes = new byte[(int) file.length()];
try (FileInputStream fileInputStream = new FileInputStream(file)) {
fileInputStream.read(bytes);
} catch (IOException e) {
e.printStackTrace();
}
// 执行 defineClass 方法 返回 Class 对象
return (Class<?>) defineClass.invoke(Thread.currentThread().getContextClassLoader(), bytes, 0, bytes.length);
}

由于TestHello中的方法为静态方法,使用class反射机制执行方法

1
2
3
4
5
6
7
// 执行编译任务
Boolean call = task.call();
if (call) {
Class<?> o = loadClassFromDisk("/Users/oneyoung/oneyoung/project/my/code/src/main/java/top/oneyoung/dynamic/TestHello.class");
Method main = o.getMethod("main", String[].class);
main.invoke(null, new Object[]{new String[]{}});
}
1
this is a test

源码字符串 -> 字节码文件

在流程图中,getTask().call()会通过调用作为参数传入的JavaFileObject对象的getCharContent()方法获得字符串序列,即源码的读取是通过 JavaFileObjectgetCharContent()方法,那我们只需要重写getCharContent()方法,即可将我们的字符串源码装进JavaFileObject了。

构造SourceJavaFileObject实现定制的JavaFileObject对象,用于存储字符串源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class SourceJavaFileObject extends SimpleJavaFileObject {

/**
* The source code of this "file".
*/
private final String code;

SourceJavaFileObject(String name, String code) {
super(URI.create("string:///" + name.replace('.', '/') + Kind.SOURCE.extension), Kind.SOURCE);
this.code = code;
}

@Override
public CharSequence getCharContent(boolean ignoreEncodingErrors) {
return code;
}
}

则创建JavaFileObject对象时,变为了:

1
2
3
4
// 通过源代码字符串获取到要编译的Java类源码迭代器,包括所有内部类,其中每个类都是一个JavaFileObject
SourceJavaFileObject javaFileObject = new SourceJavaFileObject("TestHello", "public class TestHello { public static void main(String[] args) { System.out.println(\"Hello World\"); } }");
// 生成编译任务
JavaCompiler.CompilationTask task = compiler.getTask(null, fileManager, null, null, null, Collections.singleton(javaFileObject));

执行后,同样编译出了class文件,不过由于没有指定编译的class输出路径,他会默认放在源文件的根目录下

image-20220412160252209

后面同样可以通过defindClass加载字节码完成加载。

源码字符串 -> 字节码数组

如果我们进行动态编译时,想要直接输入源码字符串并且输出的是字节码数组,而不是输出字节码文件,又该如何实现?实际上,这是从内存中得到源码,再输出到内存的方式。

getTask().call()源代码执行流程图中,我们可以发现JavaFileObjectopenOutputStream()方法控制了编译后字节码的输出行为,编译完成后会调用openOutputStream获取输出流,并写数据(字节码)。所以我们需要重写JavaFileObjectopenOutputStream()方法。

同时在执行流程图中,我们还发现用于输出的JavaFileObject 对象是JavaFileManagergetJavaFileForOutput()方法提供的,所以为了让编译器编译完成后,将编译得到的字节码输出到我们自己构造的JavaFileObject 对象,我们还需要自定义JavaFileManager`。

这里我使用类委托的方式,把大部分功能委托给了传入的StandardJavaFileManager,主要是重写了getJavaFileForOutput,使输出编译完成的字节码文件为字节数组。

然后增加了方法getBytesByClassName获取编译完成的字节码字节数组

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
public class ByteArrayJavaFileManager implements JavaFileManager {
private static final Logger LOG = LoggerFactory.getLogger(ByteArrayJavaFileManager.class);


private final StandardJavaFileManager fileManager;

/**
* synchronizing due to ConcurrentModificationException
*/
private final Map<String, ByteArrayOutputStream> buffers = Collections.synchronizedMap(new LinkedHashMap<>());

public ByteArrayJavaFileManager(StandardJavaFileManager fileManager) {
this.fileManager = fileManager;
}

@Override
public ClassLoader getClassLoader(Location location) {
return fileManager.getClassLoader(location);
}

@Override
public synchronized Iterable<JavaFileObject> list(Location location, String packageName, Set<Kind> kinds, boolean recurse) throws IOException {
return fileManager.list(location, packageName, kinds, recurse);
}

@Override
public String inferBinaryName(Location location, JavaFileObject file) {
return fileManager.inferBinaryName(location, file);
}

@Override
public boolean isSameFile(FileObject a, FileObject b) {
return fileManager.isSameFile(a, b);
}

@Override
public synchronized boolean handleOption(String current, Iterator<String> remaining) {
return fileManager.handleOption(current, remaining);
}

@Override
public boolean hasLocation(Location location) {
return fileManager.hasLocation(location);
}

@Override
public JavaFileObject getJavaFileForInput(Location location, String className, Kind kind) throws IOException {

if (location == StandardLocation.CLASS_OUTPUT) {
boolean success;
final byte[] bytes;
synchronized (buffers) {
success = buffers.containsKey(className) && kind == Kind.CLASS;
bytes = buffers.get(className).toByteArray();
}
if (success) {

return new SimpleJavaFileObject(URI.create(className), kind) {
@Override
public InputStream openInputStream() {
return new ByteArrayInputStream(bytes);
}
};
}
}
return fileManager.getJavaFileForInput(location, className, kind);
}

@Override
public JavaFileObject getJavaFileForOutput(Location location, final String className, Kind kind, FileObject sibling) {
return new SimpleJavaFileObject(URI.create(className), kind) {
@Override
public OutputStream openOutputStream() {
// 字节输出流用与FileManager输出编译完成的字节码文件为字节数组
ByteArrayOutputStream bos = new ByteArrayOutputStream();
// 将每个需要加载的类的输出流进行缓存
buffers.putIfAbsent(className, bos);
return bos;
}
};
}

@Override
public FileObject getFileForInput(Location location, String packageName, String relativeName) throws IOException {
return fileManager.getFileForInput(location, packageName, relativeName);
}

@Override
public FileObject getFileForOutput(Location location, String packageName, String relativeName, FileObject sibling) throws IOException {
return fileManager.getFileForOutput(location, packageName, relativeName, sibling);
}

@Override
public void flush() {
// Do nothing
}

@Override
public void close() throws IOException {
fileManager.close();
}

@Override
public int isSupportedOption(String option) {
return fileManager.isSupportedOption(option);
}

public void clearBuffers() {
buffers.clear();
}


public Map<String, byte[]> getAllBuffers() {
Map<String, byte[]> ret = new LinkedHashMap<>(buffers.size() * 2);
Map<String, ByteArrayOutputStream> compiledClasses = new LinkedHashMap<>(ret.size());
synchronized (buffers) {
compiledClasses.putAll(buffers);
}
compiledClasses.forEach((k, v) -> ret.put(k, v.toByteArray()));
return ret;
}

public byte[] getBytesByClassName(String className) {
return buffers.get(className).toByteArray();
}
}

然后我们修改下之前的执行流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void fromJavaSourceToByteArray1() throws Exception {
// 获取Javac编译器对象
JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
// 获取文件管理器:负责管理类文件的输入输出
StandardJavaFileManager fileManager = compiler.getStandardFileManager(null, null, null);
// 创建自定义的FileManager
ByteArrayJavaFileManager byteArrayJavaFileManager = new ByteArrayJavaFileManager(fileManager);
// 通过源代码字符串获取到要编译的Java类源码迭代器,包括所有内部类,其中每个类都是一个JavaFileObject
JavaFileObject javaFileObject = new SourceJavaFileObject("TestHello", "public class TestHello { public static void say(String args) { System.out.println(args); } }");

JavaCompiler.CompilationTask task = compiler.getTask(null, byteArrayJavaFileManager, null, null, null, Collections.singletonList(javaFileObject));
// 执行编译任务
Boolean call = task.call();
if (Boolean.TRUE.equals(call)) {
byte[] testHellos = byteArrayJavaFileManager.getBytesByClassName("TestHello");
Class<ClassLoader> classLoaderClass = ClassLoader.class;
Method defineClass = classLoaderClass.getDeclaredMethod("defineClass", byte[].class, int.class, int.class);
defineClass.setAccessible(true);
Object invoke = defineClass.invoke(TestHello.class.getClassLoader(), testHellos, 0, testHellos.length);
Class clazz = (Class) invoke;
clazz.getMethod("say", String.class).invoke(null, "你好");

}
}

成功输出

1
你好

亦称:装饰者模式、装饰器模式、Wrapper、Decorator

意图

装饰模式是一种结构型设计模式, 允许你通过将对象放入包含行为的特殊封装对象中来为原对象绑定新的行为。

装饰设计模式

问题

假设你正在开发一个提供通知功能的库, 其他程序可使用它向用户发送关于重要事件的通知。

库的最初版本基于 通知器Notifier类, 其中只有很少的几个成员变量, 一个构造函数和一个 send发送方法。 该方法可以接收来自客户端的消息参数, 并将该消息发送给一系列的邮箱, 邮箱列表则是通过构造函数传递给通知器的。 作为客户端的第三方程序仅会创建和配置通知器对象一次, 然后在有重要事件发生时对其进行调用。

使用装饰模式前的库结构

程序可以使用通知器类向预定义的邮箱发送重要事件通知。

此后某个时刻, 你会发现库的用户希望使用除邮件通知之外的功能。 许多用户会希望接收关于紧急事件的手机短信, 还有些用户希望在微信上接收消息, 而公司用户则希望在 QQ 上接收消息。

实现其他类型通知后的库结构

每种通知类型都将作为通知器的一个子类得以实现。

这有什么难的呢? 首先扩展 通知器类, 然后在新的子类中加入额外的通知方法。 现在客户端要对所需通知形式的对应类进行初始化, 然后使用该类发送后续所有的通知消息。

但是很快有人会问: “为什么不同时使用多种通知形式呢? 如果房子着火了, 你大概会想在所有渠道中都收到相同的消息吧。”

你可以尝试创建一个特殊子类来将多种通知方法组合在一起以解决该问题。 但这种方式会使得代码量迅速膨胀, 不仅仅是程序库代码, 客户端代码也会如此。

创建组合类后的程序库结构

子类组合数量爆炸。

你必须找到其他方法来规划通知类的结构, 否则它们的数量会在不经意之间打破吉尼斯纪录。

解决方案

当你需要更改一个对象的行为时, 第一个跳入脑海的想法就是扩展它所属的类。 但是, 你不能忽视继承可能引发的几个严重问题。

  • 继承是静态的。 你无法在运行时更改已有对象的行为, 只能使用由不同子类创建的对象来替代当前的整个对象。
  • 子类只能有一个父类。 大部分编程语言不允许一个类同时继承多个类的行为。

其中一种方法是用聚合组合 , 而不是继承。 两者的工作方式几乎一模一样: 一个对象包含指向另一个对象的引用, 并将部分工作委派给引用对象; 继承中的对象则继承了父类的行为, 它们自己能够完成这些工作。

你可以使用这个新方法来轻松替换各种连接的 “小帮手” 对象, 从而能在运行时改变容器的行为。 一个对象可以使用多个类的行为, 包含多个指向其他对象的引用, 并将各种工作委派给引用对象。

聚合 (或组合) 组合是许多设计模式背后的关键原则 (包括装饰在内)。 记住这一点后, 让我们继续关于模式的讨论。

继承与聚合的对比

继承与聚合的对比

封装器是装饰模式的别称, 这个称谓明确地表达了该模式的主要思想。 “封装器” 是一个能与其他 “目标” 对象连接的对象。 封装器包含与目标对象相同的一系列方法, 它会将所有接收到的请求委派给目标对象。 但是, 封装器可以在将请求委派给目标前后对其进行处理, 所以可能会改变最终结果。

那么什么时候一个简单的封装器可以被称为是真正的装饰呢? 正如之前提到的, 封装器实现了与其封装对象相同的接口。 因此从客户端的角度来看, 这些对象是完全一样的。 封装器中的引用成员变量可以是遵循相同接口的任意对象。 这使得你可以将一个对象放入多个封装器中, 并在对象中添加所有这些封装器的组合行为。

比如在消息通知示例中, 我们可以将简单邮件通知行为放在基类 通知器中, 但将所有其他通知方法放入装饰中。

装饰模式解决方案

将各种通知方法放入装饰。

客户端代码必须将基础通知器放入一系列自己所需的装饰中。 因此最后的对象将形成一个栈结构。

程序可以配置由通知装饰构成的复杂栈

程序可以配置由通知装饰构成的复杂栈。

实际与客户端进行交互的对象将是最后一个进入栈中的装饰对象。 由于所有的装饰都实现了与通知基类相同的接口, 客户端的其他代码并不在意自己到底是与 “纯粹” 的通知器对象, 还是与装饰后的通知器对象进行交互。

我们可以使用相同方法来完成其他行为 (例如设置消息格式或者创建接收人列表)。 只要所有装饰都遵循相同的接口, 客户端就可以使用任意自定义的装饰来装饰对象。

真实世界类比

装饰模式示例

穿上多件衣服将获得组合性的效果。

穿衣服是使用装饰的一个例子。 觉得冷时, 你可以穿一件毛衣。 如果穿毛衣还觉得冷, 你可以再套上一件夹克。 如果遇到下雨, 你还可以再穿一件雨衣。 所有这些衣物都 “扩展” 了你的基本行为, 但它们并不是你的一部分, 如果你不再需要某件衣物, 可以方便地随时脱掉。

装饰模式结构

装饰设计模式的结构

  1. 部件 (Component) 声明封装器和被封装对象的公用接口。
  2. 具体部件 (Concrete Component) 类是被封装对象所属的类。 它定义了基础行为, 但装饰类可以改变这些行为。
  3. 基础装饰 (Base Decorator) 类拥有一个指向被封装对象的引用成员变量。 该变量的类型应当被声明为通用部件接口, 这样它就可以引用具体的部件和装饰。 装饰基类会将所有操作委派给被封装的对象。
  4. 具体装饰类 (Concrete Decorators) 定义了可动态添加到部件的额外行为。 具体装饰类会重写装饰基类的方法, 并在调用父类方法之前或之后进行额外的行为。
  5. 客户端 (Client) 可以使用多层装饰来封装部件, 只要它能使用通用接口与所有对象互动即可。

装饰模式适合应用场景

  • 如果你希望在无需修改代码的情况下即可使用对象, 且希望在运行时为对象新增额外的行为, 可以使用装饰模式。

    • 装饰能将业务逻辑组织为层次结构, 你可为各层创建一个装饰, 在运行时将各种不同逻辑组合成对象。 由于这些对象都遵循通用接口, 客户端代码能以相同的方式使用这些对象。
  • 如果用继承来扩展对象行为的方案难以实现或者根本不可行, 你可以使用该模式。

    • 许多编程语言使用 final最终关键字来限制对某个类的进一步扩展。 复用最终类已有行为的唯一方法是使用装饰模式: 用封装器对其进行封装。

装饰模式优缺点

优点

  • 你无需创建新子类即可扩展对象的行为。
  • 你可以在运行时添加或删除对象的功能。
  • 你可以用多个装饰封装对象来组合几种行为。
  • 单一职责原则。 你可以将实现了许多不同行为的一个大类拆分为多个较小的类。

缺点

  • 在封装器栈中删除特定封装器比较困难。
  • 实现行为不受装饰栈顺序影响的装饰比较困难。
  • 各层的初始化配置代码看上去可能会很糟糕。

Java语言中的应用

使用示例: 装饰在 Java 代码中可谓是标准配置, 尤其是在与流式加载相关的代码中。

Java 核心程序库中有一些关于装饰的示例:

识别方法: 装饰可通过以当前类或对象为参数的创建方法或构造函数来识别。

编码和压缩装饰

本例展示了如何在不更改对象代码的情况下调整其行为。

最初的业务逻辑类仅能读取和写入纯文本的数据。 此后, 我们创建了几个小的封装器类, 以便在执行标准操作后添加新的行为。

第一个封装器负责加密和解密数据, 而第二个则负责压缩和解压数据。

你甚至可以让这些封装器嵌套封装以将它们组合起来。

decorators

DataSource.java: 定义了读取和写入操作的通用数据接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public interface DataSource {
/**
* 写
*
* @param data 字符串
*/
void writeData(String data);

/**
* 读
*
* @return 字符串
*/
String readData();
}

FileDataSource.java: 简单数据读写器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class FileDataSource implements DataSource {
private final String name;

public FileDataSource(String path) {
this.name = path;
}

@Override
public void writeData(String data) {
File file = new File(name);
try (OutputStream fos = new FileOutputStream(file)) {
fos.write(data.getBytes(), 0, data.length());
} catch (IOException ex) {
System.out.println(ex.getMessage());
}
}

@Override
public String readData() {
char[] buffer = null;
File file = new File(name);
try (FileReader reader = new FileReader(file)) {
buffer = new char[(int) file.length()];
reader.read(buffer);
} catch (IOException ex) {
System.out.println(ex.getMessage());
}
return new String(buffer);
}
}

DataSourceDecorator.java: 抽象基础装饰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class DataSourceDecorator implements DataSource{

private final DataSource wrapper;

DataSourceDecorator(DataSource source) {
this.wrapper = source;
}

@Override
public void writeData(String data) {
wrapper.writeData(data);
}

@Override
public String readData() {
return wrapper.readData();
}
}

EncryptionDecorator.java: 加密装饰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class EncryptionDecorator extends DataSourceDecorator {

EncryptionDecorator(DataSource source) {
super(source);
}

@Override
public void writeData(String data) {
super.writeData(encode(data));
}

@Override
public String readData() {
return decode(super.readData());
}

private String encode(String data) {
byte[] result = data.getBytes();
for (int i = 0; i < result.length; i++) {
result[i] += (byte) 1;
}
return Base64.getEncoder().encodeToString(result);
}

private String decode(String data) {
byte[] result = Base64.getDecoder().decode(data);
for (int i = 0; i < result.length; i++) {
result[i] -= (byte) 1;
}
return new String(result);
}
}

CompressionDecorator.java: 压缩装饰

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class CompressionDecorator extends DataSourceDecorator {
private int compLevel = 6;

public CompressionDecorator(DataSource source) {
super(source);
}

public int getCompressionLevel() {
return compLevel;
}

public void setCompressionLevel(int value) {
compLevel = value;
}

@Override
public void writeData(String data) {
super.writeData(compress(data));
}

@Override
public String readData() {
return decompress(super.readData());
}

private String compress(String stringData) {
byte[] data = stringData.getBytes();
try {
ByteArrayOutputStream bout = new ByteArrayOutputStream(512);
DeflaterOutputStream dos = new DeflaterOutputStream(bout, new Deflater(compLevel));
dos.write(data);
dos.close();
bout.close();
return Base64.getEncoder().encodeToString(bout.toByteArray());
} catch (IOException ex) {
ex.printStackTrace();
return null;
}
}

private String decompress(String stringData) {
byte[] data = Base64.getDecoder().decode(stringData);
try (InputStream in = new ByteArrayInputStream(data);
InflaterInputStream iin = new InflaterInputStream(in);
ByteArrayOutputStream bout = new ByteArrayOutputStream(512)) {
int b;
while ((b = iin.read()) != -1) {
bout.write(b);
}
return bout.toString();
} catch (IOException ex) {
return null;
}
}
}

DecoratorTest.java: 客户端代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class DecoratorTest {

public static void main(String[] args) {
String salaryRecords = "装饰模式真厉害啊";
DataSourceDecorator encoded = new CompressionDecorator(
new EncryptionDecorator(
new FileDataSource("./target/OutputDemo.txt")
)
);
encoded.writeData(salaryRecords);
DataSource plain = new FileDataSource("./target/OutputDemo.txt");

System.out.println("- Input ----------------");
System.out.println(salaryRecords);
System.out.println("- Encoded --------------");
System.out.println(plain.readData());
System.out.println("- Decoded --------------");
System.out.println(encoded.readData());
}
}

OutputDemo.txt: 执行结果

1
2
3
4
5
6
- Input ----------------
装饰模式真厉害啊
- Encoded --------------
Zkt4Q0hCRW8wLGpraWZubXRQYnBwZlg5ayxmZG8sWFBqZlh2dCxYV2p1bUVGU1o+
- Decoded --------------
装饰模式真厉害啊

前言

关于代理模式的介绍,可以看我的另外一篇文章设计模式-代理模式

这篇文章主要以代码演示Java语言的实现。

介绍

代理又可以分为静态代理和动态代理两种,下面大概说下两者的主要区别

  1. 静态代理,被代理类必须基于接口实现,必须编写代理类,代理类与被代理类实现相同的接口。
  2. 动态代理
    1. JDK,被代理类必须基于接口实现,不用编写代理实现,需要编写InvocationHandler实现
    2. CgLib,可以代理类、接口,需要编写MethodInterceptor实现

静态代理

电影是电影公司委托给影院进行播放的,但是影院可以在播放电影的时候,产生一些自己的经济收益,比如提供按摩椅,娃娃机(这个每次去电影院都会尝试下,基本上是夹不起来,有木有大神可以传授下诀窍),卖爆米花、饮料(贵的要死,反正吃不起)等。我们平常去电影院看电影的时候,在电影开始的阶段是不是经常会放广告呢?然后在影片开始结束时播放一些广告。

下面我们通过代码来模拟下电影院这一系列的赚钱操作。

首先得有一个接口,通用的接口是代理模式实现的基础。这个接口我们命名为 Cnima,代表电影播放的能力。

1
2
3
4
5
6
7
8
9
public interface Cinema {

/**
* 播放电影
*
* @param filmName name
*/
void play(String filmName);
}
  • 接下来我们要创建一个真正的实现这个 Cinema 接口的类,和一个实现该接口的代理类。

真正的实现类

1
2
3
4
5
6
7
public class DefaultCinema implements Cinema {

@Override
public void play(String filmName) {
System.out.println("现在正在播放电影:" + filmName);
}
}
  • 代理类
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class CinemaProxy implements Cinema {

private final Cinema cinema;

public CinemaProxy() {
this.cinema = new DefaultCinema();
}

@Override
public void play(String filmName) {
CinemaProxyTask.playStartAd();
cinema.play(filmName);
CinemaProxyTask.playEndAd();
}
}
  • 代理任务类
1
2
3
4
5
6
7
8
9
10
public class CinemaProxyTask {

public static void playStartAd() {
System.out.println("正在播放片头广告");
}

public static void playEndAd() {
System.out.println("正在播放片尾广告");
}
}
  • 测试类
1
2
3
4
5
6
7
8
9
10
11
12
public class MainTest {

public static void main(String[] args) {
String filmName = "中国医生";
staticProxy(filmName);
}

public static void staticProxy(String filmName) {
Cinema proxyCinema = new CinemaProxy();
proxyCinema.play(filmName);
}
}

image-20220102164025624

  • 运行结果
1
2
3
正在播放片头广告
现在正在播放电影:中国医生
正在播放片尾广告

现在可以看到,代理模式可以在不修改被代理对象的基础上,通过扩展代理类,进行一些功能的附加与增强。值得注意的是,代理类和被代理类应该共同实现一个接口,或者是共同继承某个类。这个就是是静态代理的内容,为什么叫做静态呢?因为它的类型是事先预定好的,比如上面代码中的 CinemaProxy 这个类。

优点

  • 代理模式在客户端与目标对象之间起到一个中介作用和保护目标对象的作用
  • 代理对象可以扩展目标对象的功能
  • 代理模式能将客户端与目标对象分离,在一定程度上降低了系统的耦合度。

缺点

  • 代理对象需要与目标对象实现一样的接口,所以会有很多代理类,类太多.同时,一旦接口增加方法,目标对象与代理对象都要维护。

与装饰者模式的比较

  • 装饰静态代理有着相似的结构, 但是其意图却非常不同。 这两个模式的构建都基于组合原则, 也就是说一个对象应该将部分工作委派给另一个对象。 两者之间的不同之处在于代理通常自行管理其服务对象的生命周期, 而装饰的生成则总是由客户端进行控制。

JDK 动态代理

与静态代理类对照的是动态代理类,动态代理类的字节码在程序运行时由 Java 反射机制动态生成,无需程序员手工编写它的源代码。动态代理类不仅简化了编程工作,而且提高了软件系统的可扩展性,因为 Java 反射机制可以生成任意类型的动态代理类。java.lang.reflect 包中的 Proxy 类和InvocationHandler 接口提供了生成动态代理类的能力。

还是刚才的Cinema接口,我们编写个动态代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class JdkCinemaProxy implements InvocationHandler {

private final Object object;

public JdkCinemaProxy(Object object) {
this.object = object;
}

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
Object invoke = null;
if (method.getName().equals("play")) {
CinemaProxyTask.playStartAd();
invoke = method.invoke(object, args);
CinemaProxyTask.playEndAd();
}
return invoke;
}
}

这个invoke方法有很多参数

  • proxy,这个是生成的代理类的实例
  • method,表示代理的方法
  • args,表示传入方法的参数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

public class MainTest {

public static void main(String[] args) {
String filmName = "中国医生";
dynamicJdkProxy(filmName);
}

public static void dynamicJdkProxy(String filmName) {
// 保存动态生成的代理类
System.getProperties().put("jdk.proxy.ProxyGenerator.saveGeneratedFiles", "true");
DefaultCinema defaultCinema = new DefaultCinema();
JdkCinemaProxy jdkCinemaProxy = new JdkCinemaProxy(defaultCinema);
// 动态生成代理类,返回的Object 就是生成的代理类实例
Object o = Proxy.newProxyInstance(Thread.currentThread().getContextClassLoader(),
new Class[]{Cinema.class}, jdkCinemaProxy);
Cinema cinema = (Cinema) o;
cinema.play(filmName);
}
}
  • newProxyInstance,方法需要你传入,类加载器、需要代理的接口,以及处理的InvocationHandler
  • 运行时会生成代理类,并实时加载进JVM。

我们来看下动态生成的代理类。

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
public final class $Proxy0 extends Proxy implements Cinema {
private static Method m1;
private static Method m2;
private static Method m3;
private static Method m0;

public $Proxy0(InvocationHandler var1) throws {
super(var1);
}

public final boolean equals(Object var1) throws {
try {
return (Boolean)super.h.invoke(this, m1, new Object[]{var1});
} catch (RuntimeException | Error var3) {
throw var3;
} catch (Throwable var4) {
throw new UndeclaredThrowableException(var4);
}
}

public final String toString() throws {
try {
return (String)super.h.invoke(this, m2, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}

public final void play(String var1) throws {
try {
super.h.invoke(this, m3, new Object[]{var1});
} catch (RuntimeException | Error var3) {
throw var3;
} catch (Throwable var4) {
throw new UndeclaredThrowableException(var4);
}
}

public final int hashCode() throws {
try {
return (Integer)super.h.invoke(this, m0, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}

static {
try {
m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
m2 = Class.forName("java.lang.Object").getMethod("toString");
m3 = Class.forName("top.oneyoung.design.proxy.Cinema").getMethod("play", Class.forName("java.lang.String"));
m0 = Class.forName("java.lang.Object").getMethod("hashCode");
} catch (NoSuchMethodException var2) {
throw new NoSuchMethodError(var2.getMessage());
} catch (ClassNotFoundException var3) {
throw new NoClassDefFoundError(var3.getMessage());
}
}
}

  • 我们可以看到,该类有四个成员变量分别对应Object的三个公共方法以及一个Cinema接口的play方法。
  • 构造方法需要传入IvocationHandler,并继续传入父类的构造方法。这里传入的IvocationHandler的其实就是我们刚才编写的JdkCinemaProxy
  • 代理类的play方法,super.h.invoke(this, m3, new Object[]{var1});
    • super.h就是我们编写的JdkCinemaProxy
    • 然后调用invoke方法,完成了代理。

至此整个流程就清晰了。这就是 jdk 的动态代理。

可以看到,JDK的动态代理其实就是自动帮我们生成了代理类,避免我们手动编写。

CgLib动态代理

// TODO

什么是代理模式

先来个生活中的小例子

影视导演找演员谈合作一般是不会直接找到演员本人,而是先找到演员的经纪人,先由经纪人洽谈,经纪人觉得合适的话就会与演员本人商讨合作事项,这个过程导演与演员是不直接接触的。

这里就用到了代理模式,导演其实想找的人是演员,但是要先找到是经纪人,再由经纪人找演员沟通。真正的价值在于演员,但是这个过程中,对于导演来说,经纪人与演员体现出了同样的价值,经纪人会全权代理演员与导演洽谈,经纪人会用自己的专业性过滤掉一些不好的合作意向,从而避免演员被频繁打扰。

代理模式是一种结构型设计模式, 让你能够提供对象的替代品或其占位符。 代理控制着对于原对象的访问, 并允许在将请求提交给对象前后进行一些处理。

proxy-02.png

问题

为什么要控制对于某个对象的访问呢? 举个例子: 有这样一个消耗大量系统资源的巨型对象, 你只是偶尔需要使用它, 并非总是需要。

代理模式解决的问题

数据库查询有可能会非常缓慢

你可以实现延迟初始化: 在实际有需要时再创建该对象。 对象的所有客户端都要执行延迟初始代码。 不幸的是, 这很可能会带来很多重复代码。

在理想情况下, 我们希望将代码直接放入对象的类中, 但这并非总是能实现: 比如类可能是第三方封闭库的一部分。

解决方案

代理模式建议新建一个与原服务对象接口相同的代理类, 然后更新应用以将代理对象传递给所有原始对象客户端。 代理类接收到客户端请求后会创建实际的服务对象, 并将所有工作委派给它。

代理模式的解决方案

代理将自己伪装成数据库对象, 可在客户端或实际数据库对象不知情的情况下处理延迟初始化和缓存查询结果的工作。

这有什么好处呢? 如果需要在类的主要业务逻辑前后执行一些工作, 你无需修改类就能完成这项工作。 由于代理实现的接口与原类相同, 因此你可将其传递给任何一个使用实际服务对象的客户端。代理模式结构

代理设计模式的结构

  1. 服务接口 (Service Interface) 声明了服务接口。 代理必须遵循该接口才能伪装成服务对象。
  2. 服务 (Service) 类提供了一些实用的业务逻辑。
  3. 代理 (Proxy) 类包含一个指向服务对象的引用成员变量。 代理完成其任务 (例如延迟初始化、 记录日志、 访问控制和缓存等) 后会将请求传递给服务对象。 通常情况下, 代理会对其服务对象的整个生命周期进行管理。
  4. 客户端 (Client) 能通过同一接口与服务或代理进行交互, 所以你可在一切需要服务对象的代码中使用代理。

代理模式适合应用场景

使用代理模式的方式多种多样, 我们来看看最常见的几种。

  1. 延迟初始化 (虚拟代理)。 如果你有一个偶尔使用的重量级服务对象, 一直保持该对象运行会消耗系统资源时, 可使用代理模式。

    • 你无需在程序启动时就创建该对象, 可将对象的初始化延迟到真正有需要的时候。
  2. 访问控制 (保护代理)。 如果你只希望特定客户端使用服务对象, 这里的对象可以是操作系统中非常重要的部分, 而客户端则是各种已启动的程序 (包括恶意程序), 此时可使用代理模式。

    • 代理可仅在客户端凭据满足要求时将请求传递给服务对象。
  3. 本地执行远程服务 (远程代理)。 适用于服务对象位于远程服务器上的情形。

    • 在这种情形中, 代理通过网络传递客户端请求, 负责处理所有与网络相关的复杂细节。
  4. 记录日志请求 (日志记录代理)。 适用于当你需要保存对于服务对象的请求历史记录时。 代理可以在向服务传递请求前进行记录。

  5. 缓存请求结果 (缓存代理)。 适用于需要缓存客户请求结果并对缓存生命周期进行管理时, 特别是当返回结果的体积非常大时。

    • 代理可对重复请求所需的相同结果进行缓存, 还可使用请求参数作为索引缓存的键值。
  6. 智能引用。 可在没有客户端使用某个重量级对象时立即销毁该对象。

    • 代理会将所有获取了指向服务对象或其结果的客户端记录在案。 代理会时不时地遍历各个客户端, 检查它们是否仍在运行。 如果相应的客户端列表为空, 代理就会销毁该服务对象, 释放底层系统资源。
      • 代理还可以记录客户端是否修改了服务对象。 其他客户端还可以复用未修改的对象。

实现方式

  1. 如果没有现成的服务接口, 你就需要创建一个接口来实现代理和服务对象的可交换性。 从服务类中抽取接口并非总是可行的, 因为你需要对服务的所有客户端进行修改, 让它们使用接口。 备选计划是将代理作为服务类的子类, 这样代理就能继承服务的所有接口了。
  2. 创建代理类, 其中必须包含一个存储指向服务的引用的成员变量。 通常情况下, 代理负责创建服务并对其整个生命周期进行管理。 在一些特殊情况下, 客户端会通过构造函数将服务传递给代理。
  3. 根据需求实现代理方法。 在大部分情况下, 代理在完成一些任务后应将工作委派给服务对象。
  4. 可以考虑新建一个构建方法来判断客户端可获取的是代理还是实际服务。 你可以在代理类中创建一个简单的静态方法, 也可以创建一个完整的工厂方法。
  5. 可以考虑为服务对象实现延迟初始化。

代理模式优缺点

优点

  • 你可以在客户端毫无察觉的情况下控制服务对象。
  • 如果客户端对服务对象的生命周期没有特殊要求, 你可以对生命周期进行管理。
  • 即使服务对象还未准备好或不存在, 代理也可以正常工作。
  • 开闭原则。 你可以在不对服务或客户端做出修改的情况下创建新代理。

缺点

  • 代码可能会变得复杂, 因为需要新建许多类。
  • 服务响应可能会延迟。