2024年7月

从DDPM到DDIM (一)

现在网络上关于DDPM和DDIM的讲解有很多,但无论什么样的讲解,都不如自己推到一边来的痛快。笔者希望就这篇文章,从头到尾对扩散模型做一次完整的推导。

DDPM是一个双向马尔可夫模型,其分为扩散过程和采样过程。

扩散过程是对于图片不断加噪的过程,每一步添加少量的高斯噪声,直到图像完全变为纯高斯噪声。
为什么逐步添加小的高斯噪声,而不是一步到位,直接添加很强的噪声呢?这一点我们留到之后来探讨。

采样过程则相反,是对纯高斯噪声图像不断去噪,逐步恢复原始图像的过程。

下图展示了DDPM原文中的马尔可夫模型。
img

其中
\(\mathbf{x}_T\)
代表纯高斯噪声,
\(\mathbf{x}_t, 0 < t < T\)
代表中间的隐变量,
\(\mathbf{x}_0\)
代表生成的图像。从
\(\mathbf{x}_0\)
逐步加噪到
\(\mathbf{x}_T\)
的过程是不需要神经网络参数的,简单地讲高斯噪声和图像或者隐变量进行线性组合即可,单步加噪过程用
\(q(\mathbf{x}_t | \mathbf{x}_{t-1})\)
来表示。但是去噪的过程,我们是不知道的,这里的单步去噪过程,我们用
\(p_{\theta}(\mathbf{x}_{t-1} | \mathbf{x}_{t})\)
来表示。之所以这里增加一个
\(\theta\)
下标,是因为
\(p_{\theta}(\mathbf{x}_{t-1} | \mathbf{x}_{t})\)
是用神经网络来逼近的转移概率,
\(\theta\)
代表神经网络参数。

扩散模型首先需要大量的图片进行训练,训练的目标就是估计图像的概率分布。训练完毕后,生成图像的过程就是在计算出的概率分布中采样。因此生成模型一般都有训练算法和采样算法,VAE、GAN、diffusion,还有如今大火的大预言模型(LLM)都不例外。本文讨论的DDPM和DDIM在训练方法上是一样的,只是DDIM在采样方法上与前者有所不同。

而训练算法的最经典的方法就是极大似然估计,我们从极大似然估计开始。

1、从极大似然估计开始

首先简单回顾一下概率论中的概率论中的一些基本概念。

1.1、概念回顾

边缘概率密度和联合概率密度
: 大家可能还记得概率论中的边缘概率密度,忘了也不要紧,我们简单回顾一下。对于二维随机变量
\((X, Y)\)
,其联合概率密度函数是
\(f(x, y)\)
,那么我不管
\(Y\)
,单看
\(X\)
的概率密度,就是
\(X\)
的边缘概率密度,其计算方式如下:

\[\begin{aligned}
f_{X}(t) = \int_{-\infty}^{\infty} f(x, y) d y \\
\end{aligned} \\
\]

概率乘法公式
: 对于联合概率
\(P(A_1 A_2 ... A_{n})\)
,若
\(P(A_1 A_2 ... A_{n-1}) 0\)
,则:

\[\begin{aligned}
P(A_1 A_2 ... A_{n}) &= P(A_1 A_2 ... A_{n-1}) P(A_n | A_1 A_2 ... A_{n-1}) \\
&= P(A_1) P(A_2 | A_1) P(A_3 | A_1 A_2) ... P(A_n | A_1 A_2 ... A_{n-1})
\end{aligned} \\
\]

概率乘法公式可以用条件概率的定义和数学归纳法证明。

马尔可夫链定义
: 随机过程
\(\left\{X_n, n = 0,1,2,...\right\}\)
称为
马尔可夫链
,若随机过程在某一时刻的随机变量
\(X_n\)
只取有限或可列个值(比如非负整数集,若不另外说明,以集合
\(\mathcal{S}\)
来表示),并且对于任意的
\(n \geq 0\)
,及任意状态
\(i, j, i_0, i_1, ..., i_{n-1} \in \mathcal{S}\)
,有

\[\begin{aligned}
P(X_{n+1} = j | X_{0} = i_{0}, X_{1} = i_{1}, ... X_{n} = i) = P(X_{n+1} = j | X_{n} = i) \\
\end{aligned} \\
\]

其中
\(X_n = i\)
表示过程在时刻
\(n\)
处于状态
\(i\)
。称
\(\mathcal{S}\)
为该过程的状态空间。上式刻画了马尔可夫链的特性,称为马尔可夫性。

1.2、概率分布表示

生成模型的主要目标是估计需要生成的数据的概率分布。这里就是
\(p\left(\mathbf{x}_0\right)\)
,如何估计
\(p\left(\mathbf{x}_0\right)\)
呢。一个比较直接的想法就是把
\(p\left(\mathbf{x}_0\right)\)
当作整个马尔可夫模型的边缘概率:

\[\begin{aligned}
p\left(\mathbf{x}_0\right) = \int p\left(\mathbf{x}_{0:T}\right) d \mathbf{x}_{1:T} \\
\end{aligned} \\
\]

这里
\(p\left(\mathbf{x}_{0:T}\right)\)
表示
\(\mathbf{x}_{0}, \mathbf{x}_{1}, ..., \mathbf{x}_{T}\)
多个随机变量的联合概率分布。
\(d \mathbf{x}_{1:T}\)
表示对
\(\mathbf{x}_{1}, \mathbf{x}_{2}, ..., \mathbf{x}_{T}\)

\(T\)
个随机变量求多重积分。

显然,这个积分很不好求。Sohl-Dickstein等人在2015年的扩散模型的开山之作
[1]
中,采用的是这个方法:

\[\begin{aligned}
p\left(\mathbf{x}_0\right) &= \int p\left(\mathbf{x}_{0:T}\right) \textcolor{blue}{\frac{q\left(\mathbf{x}_{1:T} | \mathbf{x}_{0}\right)}{q\left(\mathbf{x}_{1:T} | \mathbf{x}_{0}\right)}} d \mathbf{x}_{1:T} \quad\quad 积分内部乘1\\
&= \int \textcolor{blue}{q\left(\mathbf{x}_{1:T} | \mathbf{x}_{0}\right)} \frac{p\left(\mathbf{x}_{0:T}\right)}{\textcolor{blue}{q\left(\mathbf{x}_{1:T} | \mathbf{x}_{0}\right)}} d \mathbf{x}_{1:T} \\
&= \mathbb{E}_{\textcolor{blue}{q\left(\mathbf{x}_{1:T} | \mathbf{x}_{0}\right)}} \left[\frac{p\left(\mathbf{x}_{0:T}\right)}{\textcolor{blue}{q\left(\mathbf{x}_{1:T} | \mathbf{x}_{0}\right)}}\right] \quad\quad随机变量函数的期望\\
\end{aligned} \tag{1}
\]

Sohl-Dickstein等人借鉴的是统计物理中的技巧:退火重要性采样(annealed importance sampling) 和 Jarzynski equality。这两个就涉及到笔者的知识盲区了,感兴趣的同学可以自行找相关资料学习。(果然数学物理基础不牢就搞不好科研~)。

这里有的同学可能会有疑问,为什么用分子分母都为
\(q\left(\mathbf{x}_{1:T} | \mathbf{x}_{0}\right)\)
的因子乘进去?这里笔者尝试给出另一种解释,就是我们在求边缘分布的时候,可以尝试将联合概率分布拆开,然后想办法乘一个已知的并且与其类似的项,然后将这些项分别放在分子与分母的位置,让他们分别进行比较。因为这是KL散度的形式,而KL散度是比较好算的。
\(q\left(\mathbf{x}_{1:T} | \mathbf{x}_{0}\right)\)
的好处就是也可以按照贝叶斯公式和马尔可夫性质拆解成多个条件概率的连乘积,这些条件概率与
\(p\left(\mathbf{x}_{0:T}\right)\)
拆解之后的条件概率几乎可以一一对应,而且每个条件概率表示的都是扩散过程的单步转移概率,这我们都是知道的。那么为什么不用
\(q\left(\mathbf{x}_{0:T}\right)\)
呢?其实
\(p\)

\(q\)
本质上是一种符号,
\(q\left(\mathbf{x}_{0:T}\right)\)

\(p\left(\mathbf{x}_{0:T}\right)\)
其实表示的是一个东西。

这里自然就引出了问题,这么一堆随机变量的联合概率密度,我们还是不知道啊,
\(p\left(\mathbf{x}_{0:T}\right)\)

\(q\left(\mathbf{x}_{1:T} | \mathbf{x}_{0}\right)\)
如何该表示?

利用概率乘法公式,有:

\[\begin{aligned}
p\left(\mathbf{x}_{0:T}\right) &= p\left(\mathbf{x}_{T}\right) p\left(\mathbf{x}_{T-1}|\mathbf{x}_{T}\right) p\left(\mathbf{x}_{T-2}|\mathbf{x}_{T-1},\mathbf{x}_{T}\right) ... p\left(\mathbf{x}_{0}|\mathbf{x}_{1:T}\right)\\
\end{aligned} \tag{2}
\]

我们这里是单独把
\(p\left(\mathbf{x}_{T}\right)\)
,单独提出来,这是因为
\(\mathbf{x}_{T}\)
服从高斯分布,这是我们知道的分布;如果反方向的来表示,这么表示的话:

\[\begin{aligned}
p\left(\mathbf{x}_{0:T}\right) &= p\left(\mathbf{x}_{0}\right) p\left(\mathbf{x}_{1}|\mathbf{x}_{0}\right) p\left(\mathbf{x}_{2}|\mathbf{x}_{1},\mathbf{x}_{0}\right) ... p\left(\mathbf{x}_{T}|\mathbf{x}_{0:T-1}\right)\\
\end{aligned} \tag{3}
\]

(3)式这样表示明显不如(2)式,因为我们最初就是要求
\(p\left(\mathbf{x}_{0}\right)\)
,而计算(3)式则需要知道
\(p\left(\mathbf{x}_{0}\right)\)
,这样就陷入了死循环。因此学术界采用(2)式来对联合概率进行拆解。

因为扩散模型是马尔可夫链,某一时刻的随机变量只和前一个时刻有关,所以:

\[\begin{aligned}
p\left(\mathbf{x}_{t-1}|\mathbf{x}_{\leq t}\right) = p\left(\mathbf{x}_{t-1}|\mathbf{x}_{t}\right)\\
\end{aligned} \\
\]

于是有:

\[\begin{aligned}
p\left(\mathbf{x}_{0:T}\right) = p\left(\mathbf{x}_{T}\right) \prod_{t=1}^{T} p\left(\mathbf{x}_{t-1}|\mathbf{x}_{t}\right)\\
\end{aligned} \\
\]

文章一开始说到,在扩散模型的采样过程中,单步转移概率是不知道的,需要用神经网络来拟合,所以我们给采样过程的单步转移概率都加一个下标
\(\theta\)
,这样就得到了最终的联合概率:

\[\begin{aligned}
\textcolor{blue}{p\left(\mathbf{x}_{0:T}\right) = p\left(\mathbf{x}_{T}\right) \prod_{t=1}^{T} p_{\theta}\left(\mathbf{x}_{t-1}|\mathbf{x}_{t}\right)}
\end{aligned} \tag{4}
\]

类似地,我们来计算
\(q\left(\mathbf{x}_{1:T} | \mathbf{x}_{0}\right)\)
的拆解表示:

\[\begin{aligned}
q\left(\mathbf{x}_{1:T} | \mathbf{x}_{0}\right) &= q\left(\mathbf{x}_{1} | \mathbf{x}_{0}\right) q\left(\mathbf{x}_{2} | \mathbf{x}_{0:1}\right) ... q\left(\mathbf{x}_{T} | \mathbf{x}_{0:T-1}\right) \quad\quad 概率乘法公式\\
&= \prod_{t=1}^T q\left(\mathbf{x}_{t} | \mathbf{x}_{t-1}\right) \quad\quad 马尔可夫性质\\
\end{aligned} \\
\]

于是得到了以
\(\mathbf{x}_0\)
为条件的扩散过程的联合概率分布:

\[\begin{aligned}
\textcolor{blue}{q\left(\mathbf{x}_{1:T} | \mathbf{x}_{0}\right) = \prod_{t=1}^T q\left(\mathbf{x}_{t} | \mathbf{x}_{t-1}\right)} \\
\end{aligned} \tag{5}
\]


  1. Sohl-Dickstein J, Weiss E, Maheswaranathan N, et al. Deep unsupervised learning using nonequilibrium thermodynamics[C]//International conference on machine learning. PMLR, 2015: 2256-2265.
    ↩︎

1.前言

1.1
什么是垃圾?

在提到什么是垃圾之前,我们先看下面一张图:

从上图我们可以很明确的知道,Java 和 C++ 语言的区别,就在于垃圾收集技术和内存动态分配上,C++ 语言没有垃圾收集技术,需要我们手动的收集。

垃圾收集,不是 Java 语言的伴生产物,早在1960年,第一门开始使用内存动态分配和垃圾收集技术的 Lisp 语言诞生。
关于垃圾收集有三个经典问题:

★ 哪些内存需要回收?

★ 什么时候回收?

★ 如何回收?

垃圾收集机制是 Java 的招牌能力,极大地提高了开发效率。如今,垃圾收集几乎成为现代语言的标配,即使经过如此长时间的发展,Java 的垃圾收集机制仍然在不断的演进中,不同大小的设备、不同特征的应用场景,对垃圾收集提出了新的挑战,这当然也是面试的热点。

那什么是垃圾呢?

垃圾是指在运行程序中没有任何指针指向的对象,这个对象就是需要被回收的垃圾。

如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序的结束,被保留的空间无法被其它对象使用,甚至可能导致
内存溢出


1.2 磁盘碎片整理

机械硬盘需要进行磁盘整理:

因为在我们使用磁盘的过程中,许多零碎的文件分布不均,造成了大量可用空间碎片化,同时还有坏道。


1.3 为什么需要GC?

对于高级语言来说,一个基本认知是如果不进行垃圾回收,内存迟早都会被消耗完,因为不断地分配内存空间而不进行回收,就好像不停地生产生活垃圾而从来不打扫一样。

除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端,以便 JVM 将整理出的内存分配给新的对象。

随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有 GC 就不能保证应用程序的正常进行。而经常造成 STW 的 GC 又跟不上实际的需求,所以才会不断地尝试对 GC 进行优化。


1.4 早期垃圾回收

在早期的 C/C++ 时代,垃圾回收基本上是手工进行的。开发人员可以使用
new
关键字进行内存申请,并使用
delete
关键字进行内存释放。比如以下代码:

MibBridge *pBridge= newcmBaseGroupBridge();//如果注册失败,使用Delete释放该对象所占内存区域
if(pBridge->Register(kDestroy)!=NO ERROR)delete pBridge;

这种方式可以灵活控制内存释放的时间,但是会给开发人员带来频繁申请和释放内存的管理负担。倘若有一处内存区间由于程序员编码的问题忘记被回收,那么就会产生内存泄漏,垃圾对象永远无法被清除,随着系统运行时间的不断增长,垃圾对象所耗内存可能持续上升,直到出现内存溢出并造成应用程序崩溃。

有了垃圾回收机制后,上述代码极有可能变成这样:

MibBridge *pBridge=newcmBaseGroupBridge(); 
pBridge
->Register(kDestroy);

现在,除了 Java 以外,C#、Python、Ruby 等语言都使用了自动垃圾回收的思想,也是未来发展趋势,可以说这种自动化的内存分配和回收方式已经成为了线代开发语言必备的标准。


1.5 Java垃圾回收机制

oracle官网关于垃圾回收的介绍:
官方介绍

1.5.1 优点

✔ 自动内存管理,无需开发人员手动参与内存的分配与回收,这样降低内存泄漏和内存溢出的风险。

✔ 没有垃圾回收器,Java 也会和 C++ 一样,各种悬垂指针,野指针,泄露问题让你头疼不已。

✔ 自动内存管理机制,将程序员从繁重的内存管理中释放出来,可以更专心地专注于业务开发

1.5.2 担忧

对于 Java 开发人员而言,自动内存管理就像是一个黑匣子,如果过度依赖于“自动”,那么这将会是一场灾难,最严重的就会弱化 Java 开发人员在程序出现内存溢出时定位问题和解决问题的能力。

此时,了解 JVM 的自动内存分配和内存回收原理就显得非常重要,只有在真正了解 JVM 是如何管理内存后,我们才能够在遇见 OutOfMemoryError 时,快速地根据错误异常日志定位问题和解决问题。

当需要排查各种内存溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动化”的技术实施必要的监控和调节。

1.5.3 GC主要关注的区域

GC主要关注于
方法区


中的垃圾收集:

垃圾收集器可以对年轻代回收,也可以对老年代回收,甚至是全栈和方法区的回收。
其中,Java 堆是垃圾收集器的工作重点,
从次数上讲:

★ 频繁收集:Young 区

★ 较少收集:Old 区

★ 基本不收集:Perm 区(元空间)


2.垃圾回收相关概念

2.1 System.gc()的理解

在默认情况下,通过
system.gc()
或者
Runtime.getRuntime().gc()
的调用,会显式触发 Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。

然而,
system.gc()
调用附带一个免责声明,无法保证对垃圾收集器的调用。(不能确保立即生效)

JVM 实现者可以通过
system.gc()
调用来决定 JVM 的 GC 行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之前调用
System.gc()

代码演示是否触发 GC 操作:

public classSystemGCTest {public static voidmain(String[] args) {newSystemGCTest();//提醒JVM进行垃圾回收
System.gc();//System.runFinalization();
}

@Override
protected void finalize() throwsThrowable {System.out.println("SystemGCTest 执行了 finalize方法");
}
}

但是,
system.gc()
不一定会立即触发销毁的方法,
System.runFinalization()
会强制调用失去引用的对象的
finalize()


2.2 内存溢出

内存溢出相对于内存泄漏来说,尽管更容易被理解,但是同样的,内存溢出也是引发程序崩溃的罪魁祸首之一。

由于 GC 一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现 OOM 的情况。

大多数情况下,GC 会进行各种年龄段的垃圾回收,实在不行了就放大招,来一次独占式的 Fu11 GC 操作,这时候会回收大量的内存,供应用程序继续使用。

javadoc 中对 OutOfMemoryError 的解释是:没有空闲内存,并且垃圾收集器也无法提供更多内存。通俗来讲,就是:所分配对象的内存 > 此时可供分配的空闲内存,此时若要给该对象分配内存就会出现内存溢出。

没有空闲内存,说明 Java 虚拟机的堆内存不够,原因有二:

1、Java虚拟机的堆内存设置不够。
比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小。我们可以通过参数
-Xms

-Xmx
来调整。

2、代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。

对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似
intern
字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:“
java.lang.OutOfMemoryError:PermGen space
"。

随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现 OOM,异常信息则变成了:“
java.lang.OutofMemoryError:Metaspace
"。直接内存不足,也会导致 OOM。

这里面隐含着一层意思是,在抛出 OutOfMemoryError 之前,通常垃圾收集器会被触发,尽其所能去清理出空间。
当然,也不是在任何情况下垃圾收集器都会被触发的。
比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出 OutOfMemoryError。


2.3 内存泄漏

也称作“存储渗漏”。
严格来说,只有对象不会再被程序用到了,但是 GC 又不能回收他们的情况,才叫内存泄漏。

但实际情况很多时候一些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致 00M,也可以叫做宽泛意义上的“内存泄漏”。

尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现 OutOfMemory 异常,导致程序崩溃。
注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。

以买房子为例:80平的房子,但是有10平是公摊的面积,我们是无法使用这10平的空间,这就是所谓的内存泄漏。

Java 使用可达性分析算法,最上面的数据不可达,就是需要被回收的。后期有一些对象不用了,按道理应该断开引用,但是存在一些链没有断开,从而导致没有办法被回收,造成内存泄漏。


2.4 Stop The World

Stop-The-World,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿,停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。

可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿。

➹ 分析工作必须在一个能确保一致性的快照中进行。

➹ 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上。

➹ 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证。

被 STW 中断的应用程序线程会在完成 GC 之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少 STW 的发生。

STW 事件和采用哪款 GC 无关,所有的 GC 都有这个事件。
哪怕是 G1 也不能完全避免 Stop-The-World 情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。

STW 是 JVM 在后台自动发起和自动完成的,在用户不可见的情况下,把用户正常的工作线程全部停掉。

开发中不要用
System.gc()
,会导致 Stop-The-World 的发生。


2.5 垃圾回收的并行与并发

2.5.1 并发

在操作系统中,指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理器上运行。

并发不是真正意义上的“同时进行”
,只是 CPU 把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间
来回切换
,由于 CPU 处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。

2.5.2 并行

当系统有一个以上 CPU 时,当一个 CPU 执行一个进程时,另一个 CPU 可以执行另一个进程,两个进程互不抢占 CPU 资源,可以
同时进行
,我们称之为并行(Paralle1)。

其实决定并行的因素不是 CPU 的数量,而是 CPU 的核心数量,比如一个 CPU 多个核也可以并行。

并行适合科学计算,后台处理等弱交互场景。

2.5.3 并发和并行对比

★ 并发:
指的是多个事情,在
同一时间段内
同时发生了。

★ 并行:
指的是多个事情,在
同一时间点上
同时发生了。

并发的多个任务之间是互相抢占资源的,并行的多个任务之间是不互相抢占资源的。

只有在多 CPU 或者一个 CPU 多核的情况中,才会发生并行。
否则,看似同时发生的事情,其实都是并发执行的。

2.5.4 垃圾回收的并行与并发

并行和串行,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

♦ 并行(Paralle1):
指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。如 ParNew、Parallel Scavenge、Parallel old。

♦ 串行(Serial):
相较于并行的概念,单线程执行。
如果内存不够,则程序暂停,启动 JVM 垃圾回收器进行垃圾回收,回收完,再启动程序的线程。

并发,在谈论垃圾收集器的上下文语境中,它们可以解释如下:

♦ 并发(Concurrent):
指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。用户程序在继续运行,而垃圾收集程序线程运行于另一个 CPU 上。


2.6 安全点与安全区域

2.6.1 安全点

程序执行时并非在所有地方都能停顿下来开始 GC,只有在特定的位置才能停顿下来开始 GC,这些位置称为“安全点(Safe Point)”。

Safe Point 的选择很重要,如果太少可能导致 GC 等待时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为 Safe Point,如方法调用、循环跳转和异常跳转等。

如何在 GC 发生时,检查所有线程,使其都跑到最近的安全点停顿下来呢?



抢先式中断


首先中断所有线程,如果还有线程不在安全点,就恢复线程,让线程跑到安全点。(目前没有虚拟机采用了)



主动式中断


设置一个中断标志,各个线程运行到 Safe Point 的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。(有轮询的机制)

2.6.2 安全区域

Safe Point 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safe Point。但是,程序“不执行”的时候呢?例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全点去中断挂起,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 GC 都是安全的。我们也可以把 Safe Region 看做是被扩展了的 Safe Point。

执行流程:

当线程运行到 Safe Region 的代码时,标识该线程已经进入了 Safe Relgion 状态,如果这段时间内发生 GC,JVM 会忽略标识为 Safe Region 状态的线程。

当线程即将离开 Safe Region 时,会检查 JVM 是否已经完成 GC,如果完成了,则继续运行,否则线程必须等待,直到收到可以安全离开 Safe Region 的信号为止。


2.7 四种引用概述

我们希望能描述这样一类对象:当内存空间还足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。

在JDK1.2版之后,Java对引用的概念进行了扩充,将引用分为:

★ 强引用(Strong Reference)

★ 软引用(Soft Reference)

★ 弱引用(Weak Reference)

★ 虚引用(Phantom Reference)

这4种引用强度依次逐渐减弱。
除强引用外,其他3种引用均可以在 java.lang.ref 包中找到它们的身影。如下图,显示了这3种引用类型对应的类,开发人员可以在应用程序中直接使用它们。

Reference 子类中只有
终结器引用
是包内可见的,其他3种引用类访问型均为
public
,可以在应用程序中直接使用。

✎ 强引用(StrongReference):
最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“
object obj =
new
Object();

”这种引用关系。无论任何情况下,
只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象

✎ 软引用(SoftReference):
在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

✎ 弱引用(WeakReference):
被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。

✎ 虚引用(PhantomReference):
一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。
为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知

终结器引用:

它用于实现对象的
finalize()
方法,

无需手动编码,其内部配合引用队列使用,
在 GC 时,终结器引用入队,由 Finalizer 线程通过终结器引用找到被引用对象,一个一个的弹出对象并调用其
finalize()
方法,第二次 GC 时才回收被引用的对象。

本文全面深入地探讨了Docker容器通信技术,从基础概念、网络模型、核心组件到实战应用。详细介绍了不同网络模式及其实现,提供了容器通信的技术细节和实用案例,旨在为专业从业者提供深入的技术洞见和实际操作指南。

关注【TechLeadCloud】,分享互联网架构、云服务技术的全维度知识。作者拥有10+年互联网服务架构、AI产品研发经验、团队管理经验,同济本复旦硕,复旦机器人智能实验室成员,阿里云认证的资深架构师,项目管理专业人士,上亿营收AI产品研发负责人。

一、引言

在云计算和微服务架构日益成熟的今天,Docker作为一种轻量级的容器化技术,已成为现代软件开发和部署的关键组件。Docker容器通过为应用程序提供隔离的运行环境,不仅显著提升了部署效率,而且增强了系统的可移植性和安全性。然而,随着容器化应用的规模扩大和复杂度增加,容器间的通信成为了构建高效、可靠云服务的核心挑战。

Docker容器通信是指容器实例之间以及容器与外部世界(如其他容器、主机系统、互联网资源)的数据交换。这种通信是构建基于容器的微服务架构的基石,它支持服务间的协作和数据共享,是实现服务发现、负载均衡和网络安全等关键特性的基础。

通信技术的演变

从早期的简单链接(linking)到现在的高级网络模型,Docker容器通信技术经历了显著的演变。最初,Docker容器的通信主要依赖于链接,这是一种基础的通信机制,允许容器直接通过名称互相识别和连接。然而,随着容器化应用的日益复杂化,这种简单的通信方式已不能满足需求。

近年来,随着Docker网络功能的持续进化和完善,如覆盖网络(Overlay Network)和Macvlan,容器间的通信变得更加灵活和可靠。这些高级网络模型提供了更为复杂的网络功能,如跨主机通信、网络隔离以及精细的流量控制和管理。

例如,在一个微服务架构的电子商务应用中,不同的服务(如订单处理、库存管理、用户认证)可能部署在不同的容器中。这些容器需要高效、安全地通信,以确保数据一致性和业务流程的连续性。在这种场景下,Docker的覆盖网络提供了跨多个主机的容器间通信能力,同时保证了网络流量的安全性和隔离性。

技术进展的影响

随着容器编排工具如Kubernetes的兴起,容器通信技术面临新的挑战和机遇。Kubernetes不仅提供了更加强大的容器管理能力,而且引入了更加复杂的网络模型,如CNI(Container Network Interface),进一步推动了容器通信技术的发展。

二、容器通信概述

在深入探讨Docker容器通信的具体实现和技术前,理解容器通信的基本概念和原理是至关重要的。容器通信不仅是容器技术的基础,而且是构建高效、可靠的微服务架构的关键。

容器通信的基本原理

Docker容器通信基于Linux的网络、命名空间和虚拟化技术。每个Docker容器都在自己的网络命名空间中运行,这意味着它拥有独立的网络栈(包括IP地址、路由表、端口号等)。容器间的通信需要通过一系列的网络接口和路由规则来实现。

通信方式的分类

容器通信可以分为两大类:直接通信和间接通信。

直接通信

直接通信指的是容器间的直接网络连接。最典型的例子是Docker的默认网络模式——桥接模式,其中容器通过虚拟网络接口与主机的虚拟网桥连接,实现容器间的通信。

间接通信

间接通信涉及更复杂的网络结构,如覆盖网络。在覆盖网络中,容器间的通信会经过一个虚拟的网络层,使得分布在不同主机上的容器能够互相通信。

容器通信的关键组件

容器通信依赖于几个关键组件,包括Docker守护进程、网络命名空间、虚拟网桥、虚拟网络接口和网络驱动。

Docker守护进程

Docker守护进程是Docker架构的核心,负责创建、运行和管理容器。它也处理容器的网络配置,确保容器能够正确连接到指定的网络。

网络命名空间

网络命名空间提供了隔离的网络环境,使每个容器都有自己独立的网络栈。这一特性是实现容器间网络隔离的关键。

虚拟网桥和网络接口

虚拟网桥是连接不同容器网络接口的桥梁,它允许容器共享同一个物理网络。虚拟网络接口(如veth对)则是容器与宿主机通信的媒介。

网络驱动

Docker支持多种网络驱动,如bridge、overlay、macvlan等,每种驱动提供了不同的网络特性和功能。

容器通信技术的最新进展

随着容器技术的不断进步,容器通信技术也在不断发展。例如,最近的一些研究聚焦于提高容器网络的性能,减少网络延迟,提升数据包处理的效率。此外,网络安全也成为了研究的热点,特别是在多租户环境中确保容器间通信的隔离和安全。

案例探讨

以一个大型云服务提供商为例,他们可能会在多个数据中心部署数千个容器来支持各种服务。在这种环境下,容器间的高效通信至关重要。覆盖网络在这里扮演了重要角色,它不仅提供了跨主机的容器通信能力,还支持了负载均衡和故障转移。这样的网络设计不仅保证了高可用性,还提高了整体的网络性能。

三、Docker网络模型

理解Docker的网络模型对于掌握容器通信至关重要。Docker提供了多种网络模型,以适应不同的部署和通信需求。每种网络模型都有其独特的特性和使用场景,理解这些模型是设计和部署高效容器化应用的基础。

桥接网络

桥接网络是Docker的默认网络模型,适用于单机部署的容器。在这种模型中,Docker创建了一个虚拟的网络桥(docker0),所有运行在同一宿主机上的容器都通过这个虚拟网桥进行通信。

特点和用途

  • 隔离性
    : 桥接网络为每个容器提供了独立的网络命名空间。
  • 易用性
    : 默认配置下,容器可以立即使用,无需复杂配置。
  • 适用场景
    : 适合于单机部署的小型应用或开发环境。

主机网络

在主机网络模式下,容器共享宿主机的网络命名空间。这意味着容器不是通过虚拟网络,而是直接使用宿主机的网络接口进行通信。

特点和用途

  • 性能
    : 提供了比虚拟网络更高的网络性能。
  • 不隔离
    : 网络上没有隔离,容器的网络行为与宿主机相同。
  • 适用场景
    : 高性能需求场景,如网络应用或负载较大的服务。

无网络

在无网络模型中,容器被配置为不具备网络接口。这种模式通常用于需要高安全性或者不需要网络交互的应用。

特点和用途

  • 安全性
    : 提供了极高的安全级别,因为没有外部网络访问。
  • 适用场景
    : 对安全性有极高要求的应用,如数据敏感的处理作业。

覆盖网络

覆盖网络主要用于Docker Swarm集群,支持跨多个Docker宿主机的容器互连。它通过创建一个虚拟的网络层,使得分布在不同主机上的容器能够相互通信。

特点和用途

  • 跨主机通信
    : 支持在不同主机上运行的容器之间的通信。
  • 适用场景
    : 多宿主机部署的大型应用,如微服务架构。

Macvlan网络

Macvlan网络允许容器具有独立的MAC地址,就像物理设备一样连接到物理网络。

特点和用途

  • 直接访问物理网络
    : 容器可以像物理机一样在网络上直接可见。
  • 适用场景
    : 需要容器直接出现在物理网络上的场合,如需要绕过网络虚拟化的性能敏感应用。

技术进展与案例应用

随着容器技术的发展,Docker网络模型也在不断演进。例如,最新的研究和实践集中于提高网络模型的灵活性和性能,以及支持更复杂的网络拓扑和策略。

案例:大规模微服务部署

在一个大型的微服务架构中,各个服务可能部署在不同的宿主机上。在这种情况下,覆盖网络模型提供了一个理想的解决方案,它使得跨主机的容器能够无缝通信,同时提供了必要的网络隔离和安全保障。通过使用覆盖网络,开发团队可以轻松地扩展服务,无需担心底层的网络复杂性。

四、容器通信技术核心组件

容器通信的实现依赖于多个核心组件的协同工作。这些组件不仅构成了容器通信的基础,而且对于理解如何有效地管理和优化容器网络至关重要。以下是容器通信技术的核心组件,每个组件都扮演着不可或缺的角色。

Docker守护进程(Docker Daemon)

Docker守护进程是容器生命周期管理的中心。它负责创建、运行、停止容器,并且处理容器的网络配置。Docker守护进程在后台运行,通过Docker API与其他服务和客户端进行交互。

核心功能

  • 容器管理
    : 控制容器的创建、启动、停止等生命周期事件。
  • 网络配置
    : 配置和管理容器的网络设置,包括网络模式、端口映射等。

网络命名空间(Network Namespace)

网络命名空间是Linux内核的一个特性,它为每个容器提供了隔离的网络环境。这意味着每个容器都有自己的网络栈,包括IP地址、路由规则和端口等,从而确保了网络环境的隔离和安全。

核心功能

  • 隔离
    : 实现容器间网络的隔离。
  • 独立配置
    : 为每个容器提供独立的网络配置。

虚拟网桥(Virtual Bridge)

虚拟网桥是连接多个网络接口的设备,允许不同的虚拟网络设备(如容器)相互通信。在Docker中,虚拟网桥通常用于桥接网络模式,连接宿主机和容器。

核心功能

  • 连接设备
    : 使多个网络设备能够在同一个网络下通信。
  • 流量控制
    : 管理和转发经过网桥的网络流量。

虚拟网络接口(Virtual Network Interface)

虚拟网络接口,如veth对,是一对虚拟设备,允许网络流量在它们之间传输。在容器通信中,一个veth端点位于容器内,另一个端点连接到宿主机的虚拟网桥。

核心功能

  • 通信桥梁
    : 连接容器和宿主机的网络。
  • 数据传输
    : 使数据能够在容器和宿主机之间流动。

网络驱动(Network Drivers)

Docker提供了多种网络驱动,支持不同的网络需求。例如,
bridge
驱动支持标准的桥接网络,
overlay
驱动支持跨主机的容器通信,而
macvlan
驱动则可以将容器直接连接到物理网络。

核心功能

  • 网络模型支持
    : 实现不同的网络通信模型。
  • 配置灵活性
    : 提供不同的网络配置选项和策略。

五、容器通信实战

在理解了容器通信的基础知识和核心组件之后,将这些理论应用于实际场景中是至关重要的。本章节旨在通过具体的场景和操作,展示容器通信在实战中的应用,涵盖容器间直接通信、使用端口映射实现外部通信,以及通过Docker网络实现容器间的高效通信。

场景一:容器间的直接通信

背景

在一个简单的微服务架构中,服务A需要直接与服务B通信。这两个服务都部署在同一台宿主机上的不同容器中。

操作步骤

  1. 创建用户定义网络
    :
    使用
    docker network create
    命令创建一个新的桥接网络。

  2. 启动容器
    :
    使用
    docker run
    命令启动服务A和服务B的容器,并将它们连接到刚刚创建的网络。

  3. 配置网络规则
    :
    确保容器的网络规则允许它们相互通信。

  4. 测试通信
    :
    在服务A的容器内使用网络工具(如
    curl
    )测试与服务B的通信。

场景二:使用端口映射实现外部通信

背景

一个Web应用部署在Docker容器中,需要允许外部用户通过宿主机的端口访问。

操作步骤

  1. 准备应用
    :
    准备好Web应用,并确保它可以在容器内部正常运行。

  2. 端口映射
    :
    使用
    docker run -p
    命令启动容器,将宿主机的一个端口映射到容器的Web服务端口。

  3. 外部访问
    :
    通过宿主机的IP地址和映射的端口从外部访问Web应用。

场景三:通过Docker网络实现容器间通信

背景

在一个分布式应用中,多个服务部署在不同的宿主机上,需要实现这些服务之间的通信。

操作步骤

  1. 创建覆盖网络
    :
    在Docker Swarm模式下创建一个覆盖网络。

  2. 部署服务
    :
    使用
    docker service create
    在覆盖网络上部署不同的服务。

  3. 配置DNS
    :
    利用Docker的内置DNS服务,确保容器可以通过服务名解析其他服务的地址。

  4. 测试跨主机通信
    :
    在一个服务的容器内测试与另一服务的容器的通信。

关注【TechLeadCloud】,分享互联网架构、云服务技术的全维度知识。作者拥有10+年互联网服务架构、AI产品研发经验、团队管理经验,同济本复旦硕,复旦机器人智能实验室成员,阿里云认证的资深架构师,项目管理专业人士,上亿营收AI产品研发负责人。
如有帮助,请多关注
TeahLead KrisChang,10+年的互联网和人工智能从业经验,10年+技术和业务团队管理经验,同济软件工程本科,复旦工程管理硕士,阿里云认证云服务资深架构师,上亿营收AI产品业务负责人。

32.go.Palette 一排放两个

go.Palette
是 GoJS 库中的一个组件,用于显示一组预定义的图形元素,用户可以从中选择并将其拖放到画布中。如果要在一排中显示两个
go.Palette

一、可以使用 HTML 和 CSS 来控制它们的布局。使用
display: inline-block

div
元素水平排列在同一行中

<style>
  #palette1,
  #palette2 {
    display: inline-block;
    width: 200px;
    height: 300px;
    border: 1px solid black;
    margin-right: 20px;
  }
</style>

<div id="palette1"></div>
<div id="palette2"></div>
<script>
  var $ = go.GraphObject.make;
  var palette1 = $(go.Palette, "palette1");
  var palette2 = $(go.Palette, "palette2");
</script>

二、(建议)还可以使用
gojs
自带的属性,完成
go.Palette
自定义

  • alignment
    属性设置为
    go.GridLayout.Position
    : 使节点从左开始布局。
  • wrappingColumn
    属性设置为 2,以确保节点布局为 2 列。
  • cellSize

    spacing
    属性, 控制节点之间的间距和大小。
const myPalette = $(go.Palette, "myPaletteDiv", {
  layout: $(go.GridLayout, {
    alignment: go.GridLayout.Position,
    wrappingColumn: 2,
    cellSize: new go.Size(100, 0),
    spacing: new go.Size(5, 5),
  }),
});

33.go.Palette 基本用法

<div id="myPaletteDiv"></div>
const myPalette = $(go.Palette, "myPaletteDiv", {
    layout: $(go.GridLayout, {
    alignment: go.GridLayout.Position,
    cellSize: new go.Size(100, 0),
    wrappingColumn: 2
})
  nodeTemplate: myDiagram.nodeTemplate,
  model: new go.GraphLinksModel([
    { key: "Alpha", text: "Alpha: Patron" },
    { key: "Beta", text: "Beta: Patron" },
    { key: "Gamma", text: "Gamma: Patron" },
  ]),
});
myPalette.groupTemplateMap.add('groupA', groupATemplate()) // 可以为多组
myPalette.model = new go.GraphLinksModel([ ]) // 属性可以直接写在对象里,也可以后续修复

34.创建自己指向自己的连线

主要是在节点上设置两个属性,

{
  fromLinkableSelfNode: true,
  toLinkableSelfNode: true,
}

35.设置不同的 groupTemplate 和 linkTemplate

可以使用 Group 类型的 groupTemplate 属性来设置不同的组模板。groupTemplate 属性可以接受一个函数,该函数返回一个 Group 类型的模板。
定义了两个不同的组模板,分别使用不同的颜色。使用时会默认读取分组名称。
groupTemplate

myDiagram.groupTemplateMap.add(
  "Group1",
  $(go.Group, "Auto", $(go.Shape, "Rectangle", { fill: "red" }))
);

myDiagram.groupTemplateMap.add(
  "Group2",
  $(go.Group, "Auto", $(go.Shape, "Rectangle", { fill: "green" }))
);

myDiagram.model.addNodeData({ key: 1, isGroup: true, category: "Group1" });
myDiagram.model.addNodeData({ key: 2, isGroup: true, category: "Group2" });

linkTemplate

myDiagram.linkTemplateMap.add(
  "straight", // 直线连接
  $(go.Link, $(go.Shape))
);

myDiagram.linkTemplateMap.add(
  "curved", // 曲线连接
  $(go.Link, { curve: go.Link.Bezier }, $(go.Shape))
);
myDiagram.model.addLinkData({ from: 1, to: 2, category: "straight" });
myDiagram.model.addLinkData({ from: 3, to: 4, category: "curved" });

36.监听在图形对象 GraphObject 上的右键单击

也就是获取 右键点击的对象

myDiagram.addDiagramListener("ObjectContextClicked", function (e) {
  var linkOrNode = myDiagram.findPartAt(e.diagram.firstInput.documentPoint);
  if (linkOrNode instanceof go.Link) {
    //  TODO
  }
  if (linkOrNode instanceof go.Node) {
    //  TODO
  }
});

37.定义节点/连线/canvas 背景上的右键菜单

可以结合 右键点击的对象,进行一些操作,对右键的对象,进行一些 contextMenu 中的操作

myDiagram = $(go.Diagram, "myDiagramDiv", {
  contextMenu: createContextMenu(),
});
myDiagram.nodeTemplate.contextMenu = createContextMenu();
myDiagram.linkTemplate.contextMenu = createContextMenu();

function groupContextMenu() {
  return $(
    "ContextMenu",
    "Vertical",
    $(
      "ContextMenuButton",
      $(
        go.Panel,
        "Horizontal",
        {
          alignment: go.Spot.Left,
        },
        $(go.Picture, "图片src,如果不需要图片可删除", {
          desiredSize: new go.Size(60, 30),
          margin: new go.Margin(5, 5, 5, 10),
        }),
        $(go.TextBlock, "文本")
      ),
      {
        click: (event, obj) => {}, // 右键菜单面板点击事件
      }
    )
  );
}

38.从节点动态拉出一根连线时,判断其方向是左还是右?

可以通过判断连线的起点和终点的位置来确定其方向。具体来说,可以通过比较起点和终点的 x 坐标来判断连线的方向,如果起点的 x 坐标小于终点的 x 坐标,则连线是从左向右的,反之则是从右向左的。

重写了
insertLink
方法,当用户在节点上开始拖动连线时,会调用该方法。在该方法中首先调用了
go.LinkingTool.prototype.insertLink.call(this)
来执行默认的连线操作,然后根据起点和终点的位置来设置连线的样式。如果起点的 x 坐标小于终点的 x 坐标,则将连线的颜色设置为绿色,否则将其设置为红色。

// 连线基本模板配置
myDiagram.linkTemplate = $(
  MessageLink,
  { selectionAdorned: true, curviness: 0 },
  $(go.Shape, "Rectangle", new go.Binding("stroke", "stroke").makeTwoWay()),
  $(
    go.Shape,
    { toArrow: "OpenTriangle" },
    new go.Binding("stroke", "stroke").makeTwoWay()
  )
);

// insertLink
go.LinkingTool.prototype.insertLink = function (
  fromnode,
  fromport,
  tonode,
  toport
) {
  var newlink = go.LinkingTool.prototype.insertLink.call(
    this,
    fromnode,
    fromport,
    tonode,
    toport
  );
  if (newlink !== null) {
    var model = this.diagram.model;
    if (fromnode.position.x < tonode.position.x) {
      model.setDataProperty(newlink.data, "stroke", "green"); // 从左向右的连线
    } else {
      model.setDataProperty(newlink.data, "stroke", "red"); // 从左向右的连线
    }
  }
  return newlink;
};

39.linkTemplate routing 取值

linkTemplate 是用于定义连接线的模板。而 routing 是用于定义连接线的
路径
的属性之一

如果没有设置 routing 属性,连接线的默认路径是
go.Link.Normal
。表示连接线会直接从起点到终点的位置。这种路径适用于大多数情况,但在某些情况下可能需要更复杂的路径。 以下值 :

  • go.Link.Normal
    :连接线会直接从起点到终点的位置。
  • go.Link.JumpOver
    :连接线会跳过节点,但可能会穿过其他连接线。
  • go.Link.AvoidsNodes
    :连接线会避开节点,但可能会穿过其他连接线。
  • go.Link.Orthogonal
    :连接线会沿着水平和垂直方向移动,直到到达目标节点的位置。

40.在移动连线时,改变所有点的位置

在 gojs 中,可以通过重写
go.DraggingTool.prototype.moveParts
方法来实现移动时改变所有点的位置。具体实现步骤如下:

  1. 获取被移动的部件的所有连接线(links)。
  2. 遍历连接线,获取连接线的路径(path)。
  3. 遍历路径的所有点(points),将每个点的位置加上移动的距离(deltaX 和 deltaY)。
  4. 将修改后的点数组设置回路径的
    part.points
    属性中。
MessageDraggingTool.prototype.moveParts = function (parts, offset, check) {
  go.DraggingTool.prototype.moveParts.call(this, parts, offset, check);
  var it = parts.iterator;

  while (it.next()) {
    if (it.key instanceof go.Link) {
      var link = it.key;
      var path = link.path;
      var points = path.part.points;

      if (points.length == 3) {
        // 表示直线
        var startX = it.value.point.x;
        var startY = it.value.point.y;
        var x = startX + offset.x;
        var y = startY + offset.y;
        link.diagram.model.set(link.data, "表示连线定位的字段x", "当前位置x");
        link.diagram.model.set(link.data, "表示连线定位的字段y", "当前位置y");
      } else {
        // 表示曲线
        var pointArray: any = [];
        for (var i = 0; i < points.length; i++) {
          var point = points._dataArray[i];
          pointArray.push(new go.Point(point.x + offset.x, point.y + offset.y));
        }
        path.part.points = pointArray;
      }
    }
  }
};

@action
装饰器在Django REST Framework (DRF) 中非常有用,它可以帮助你在ViewSet中创建自定义的动作,而不仅仅是依赖标准的CRUD操作(Create, Read, Update, Delete)。以下是
@action
装饰器的一些常见用法:

1. 创建自定义集合动作

detail=False
表示这个动作是针对整个集合的。例如,你可以创建一个获取所有用户统计信息的动作:

from rest_framework.decorators importactionfrom rest_framework.response importResponsefrom rest_framework importviewsetsclassUserViewSet(viewsets.ModelViewSet):
queryset
=User.objects.all()
serializer_class
=UserSerializer

@action(detail
=False, methods=['get'])defstatistics(self, request):
user_count
=User.objects.count()return Response({'user_count': user_count})

2. 创建自定义实例动作

detail=True
表示这个动作是针对单个实例的。例如,你可以创建一个标记用户为活跃的动作:

from rest_framework.decorators importactionfrom rest_framework.response importResponsefrom rest_framework importviewsetsclassUserViewSet(viewsets.ModelViewSet):
queryset
=User.objects.all()
serializer_class
=UserSerializer

@action(detail
=True, methods=['post'])def activate(self, request, pk=None):
user
=self.get_object()
user.is_active
=True
user.save()
return Response({'status': 'user activated'})

3. 支持多种HTTP方法

你可以指定动作支持的HTTP方法。例如,你可以创建一个既支持GET又支持POST的动作:

from rest_framework.decorators importactionfrom rest_framework.response importResponsefrom rest_framework importviewsetsclassUserViewSet(viewsets.ModelViewSet):
queryset
=User.objects.all()
serializer_class
=UserSerializer

@action(detail
=False, methods=['get', 'post'])defcustom_action(self, request):if request.method == 'GET':return Response({'message': 'This is a GET request'})elif request.method == 'POST':
data
=request.datareturn Response({'message': 'This is a POST request', 'data': data})

4. 指定URL路径和名称

你可以指定自定义动作的URL路径和名称。例如:

from rest_framework.decorators importactionfrom rest_framework.response importResponsefrom rest_framework importviewsetsclassUserViewSet(viewsets.ModelViewSet):
queryset
=User.objects.all()
serializer_class
=UserSerializer

@action(detail
=True, methods=['post'], url_path='set-password', url_name='set_password')def set_password(self, request, pk=None):
user
=self.get_object()
new_password
= request.data.get('password')
user.set_password(new_password)
user.save()
return Response({'status': 'password set'})

5. 使用权限和认证

你可以为自定义动作设置权限和认证。例如:

from rest_framework.decorators importactionfrom rest_framework.response importResponsefrom rest_framework importviewsetsfrom rest_framework.permissions importIsAuthenticatedclassUserViewSet(viewsets.ModelViewSet):
queryset
=User.objects.all()
serializer_class
=UserSerializer

@action(detail
=True, methods=['get'], permission_classes=[IsAuthenticated])def profile(self, request, pk=None):
user
=self.get_object()
serializer
=self.get_serializer(user)return Response(serializer.data)

6. 返回自定义响应

你可以在自定义动作中返回任何类型的响应。例如,文件下载、重定向等:

from rest_framework.decorators importactionfrom rest_framework.response importResponsefrom rest_framework importviewsetsfrom django.http importFileResponseclassDocumentViewSet(viewsets.ModelViewSet):
queryset
=Document.objects.all()
serializer_class
=DocumentSerializer

@action(detail
=True, methods=['get'])def download(self, request, pk=None):
document
=self.get_object()
file_handle
=document.file.open()
response
= FileResponse(file_handle, content_type='application/pdf')
response[
'Content-Disposition'] = f'attachment; filename="{document.filename}"' return response

总结

@action
装饰器为Django REST Framework中的ViewSet提供了极大的灵活性,允许你在标准的CRUD操作之外添加自定义逻辑和功能。这使得你能够创建更加丰富和复杂的API端点,满足具体的业务需求。