pHash Alogirithm

感知哈希算法

在数字图像处理领域中,认为图像在本质上是一种信号,表达图像的方式有两种,一种是时域方式,它由点阵构成,里面的每一个点存储着该位置对应的颜色值称为像素,整体就形成了人眼中的图像, Windows操作系统的位图格式(Bitmap)就是一种典型的时域信号形式;另一种是频域方式,图像由一组不同频率的正弦波和余弦波叠加而成。时域信号与频域信号可以相互转换,常见的有傅立叶变换FFT、离散余弦变换DCT及他们的逆变换iFFT、iDCT等,目前广为使用的JEPG格式使用的就是DCT/iDCT变换。

频域信号的特点是低频信号表达了图像的轮廓信息,高频信号则表达了细节信息。pHash算法正是利用这一特点,通过对比图像间低频信号特征来计算图像的相似度。以下为不同频率的信号对图像的影响情况:

DCT

其中,是频域系数矩阵元素的座标,是元素的值; 是时域像素矩阵元素的座标,是元素的值;是两个矩阵的阶。

算法描述

  • 步骤1图像处理:首先将图片转换成灰阶并缩小至固定的
  • 步骤2 信号变换:使用DCT将图片转换为频域系数矩阵,记作
  • 步骤3 低频截取:从左上角第二行第二列开始截取获得低频矩阵,记作
  • 步骤4 二值化:计算矩阵的均值,记为,遍历矩阵的每个元素e,若则记为1,否则记0。得到表征图像特征的二进制串哈希值。
  • 步骤5 相似度计算:计算两个图像哈希值的海明距离来表征图像间的相似度,距离越大则相似度低,反之则相似度越高

实践中也有在步骤1图像处理的时候使用模糊滤镜进行去噪,以及在步骤4二值化中使用中值代替均值的做法,需要进行性能测试验证其有效性。

算法实现

https://github.com/klesh/qt-phash

性能测试

测试目标

测量在感知哈希算法中,对图像处理时使用不同的去噪算法及二值时使用不同的阈值对最终表现的影响。根据测试结果选择最优组合。

测试设计

测试数据:使用一组随机图片作为样本,并对应生成五组对照图片,分别为缩略组、旋转组、模糊组、裁切组、高对比组。即,同一张图片分别对应有五张相似图片,不同的图片间则互为不相似图片。

测试组合:图像处理和二值化是算法的不同步骤,因此需要分别取他们的可能值形成所有可能组合,并对这些组合进行对比。图像处理时的去噪算法有三种可能取值:不去噪、盒模糊去噪及高斯模糊;二值化时阈值有两种取法:均值、中值。共有六种可能组合:不去噪-均值、盒模糊-均值、高斯模糊-均值、不去噪-中值、盒模糊-中值、高斯模糊-中值。

测试方法:图像哈希算法应反映出图片间真实的差异程度,因此,它应在同时满足两个条件:一、 对相似的两张图片相似输出较低的海明距离,二、 对不相似的两张图片输出较高的海明距离。因此,首先要计算出这些组合对于相似图片及不相似图片输出的距离数值,对比这些距离的均值将反映出组合对性能的影响,距离的方差反映出对算法稳定性的影响。

测试实现

图像卷积操作实现:

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
QImage convolve(const QImage &source, const float (&kernel)[KS][KS]) {
assert(source.format() == QImage::Format_Grayscale8);
const int w = source.width(), h = source.height();
QImage target(w, h, source.format());
const int kcx = KS/2, kcy = kcx; // center position of the kernel
#pragma omp parallel for
for (int y = 0; y < h; y++) { // fill out target pixel one by one.
for (int x = 0; x < w; x++) {
int ksx = x - kcx, ksy = y - kcy, kex = x + kcx + 1, key = y + kcy + 1;
assert ( kex - ksx == KS );
assert ( key - ksy == KS );
ksx = ksx < 0 ? 0 : ksx; // kernel crop. (called Neumann method in CImg)
ksy = ksy < 0 ? 0 : ksy; // ksx = kernel starting x, kex = kernel ending x
kex = kex > w ? w : kex;
key = key > h ? h : key;
// calculate pixel value by image kernel
float totalValue = 0.0, totalWeight = 0.0;
for (int ky = ksy; ky < key; ky++) {
for (int kx = ksx; kx < kex; kx++) {
int i = kx - ksx, j = ky - ksy;
float weight = kernel[i][j];
totalWeight += weight;
float value = source.scanLine(ky)[kx];
totalValue += weight * value;
}
}
target.scanLine(y)[x] = totalValue / totalWeight;
}
}
return target;
}

求第k个最小值算法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
template<typename T>
T kthSmallest(T *array, int index, int low, int high) {
int l = low, r = high;
while (true) {
int m = (l + r + 1) >> 1;
if (array[l] > array[m]) std::swap(array[l], array[m]);
if (array[r] > array[m]) std::swap(array[r], array[m]);
if (array[l] > array[r]) std::swap(array[l], array[r]);
int p = r--;
while (true) {
while (array[l] < array[p]) l++;
while (array[r] > array[p]) r--;
if (l > r) break;
std::swap(array[l], array[r]);
}
std::swap(array[l], array[p]);
if (l == index) break;
else if (index < l) r = high = l - 1, l = low;
else if (index > l) l = low = l + 1, r = high;
}
return array[index];
};

求取矩阵中值算法实现:

1
2
3
4
5
6
7
8
9
10
template<int N, int M, typename T>
T matrixMedian(QGenericMatrix<N, M, T> matrix) {
const int S = N * M, m = S >> 1, n = m - 1, e = S - 1;
T array[S];
matrix.copyDataTo(array);
const T mv = kthSmallest(array, m, 0, e);
if (S % 2) return mv;
const T nv = kthSmallest(array, n, 0, n);
return (mv + nv) / 2;
}

测试数据获取与生成:unsplash.com 是一个提供免费图片的网站,同时提供了随机图片获取的接口。利用 UNIX 系统下的命令和工具可以很方便地获得所需的测试数据。之后再通过开源 imagemagick 工具软件,可以很方便地对图片进行变换生成我们需要的相似图片:

获取随机图片脚本:

1
2
3
4
5
#!/usr/bin/env fish

for i in (seq 99)
curl -L 'https://source.unsplash.com/random' -o original/(printf %02d $i).jpg
end

生成相似图片脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/env fish

rm -rf samples testsets
mkdir -p samples \
testsets/thumbnail \
testsets/rotate \
testsets/blur \
testsets/crop \
testsets/contrast
for img in (ls original)
convert original/$img -resize 640x480 samples/$img
convert samples/$img -blur 2x2 testsets/blur/$img
convert samples/$img -rotate 1 testsets/rotate/$img
convert samples/$img -resize 120x80 testsets/thumbnail/$img
convert samples/$img -gravity Center -crop 98%x+1%+1% testsets/crop/$img
convert samples/$img -level 10% testsets/contrast/$img
end

测试结果

本次测试使用了100张随机下载的图片,分别抽取20张/50张和全量数量进行测量。结果如下:

20 张图片测试结果

组合 相似图片 不相似图片
Mean Variance Mean Variance
none-mean 2.83 11.18 20.92 106.18
none-median 2.64 12.39 20.93 106.15
box-mean 2.48 9.67 21.26 114.47
box-median 2.82 7.85 20.97 105.99
gaussian-mean 2.34 8.06 20.99 108.08
gaussian-median 2.8 10.24 21.05 108.16

50 张图片测试结果

组合 相似图片 不相似图片
Mean Variance Mean Variance
none-mean 2.8 10.72 20.9 85.56
none-median 2.85 11.76 21.12 86.13
box-mean 3.05 13.3 21.06 84.17
box-median 3.26 9.96 21.09 86.39
gaussian-mean 2.44 8.06 21.07 84.48
gaussian-median 2.85 10.1 21.07 85.44

100 张图片测试结果

组合 相似图片 不相似图片
Mean Variance Mean Variance
none-mean 2.77 11.33 20.6 110.74
none-median 2.85 11.05 21.37 114.27
box-mean 2.83 12.28 21.73 124.97
box-median 3.28 10.03 21.38 117.19
gaussian-mean 2.43 8.45 21.62 120.99
gaussian-median 2.71 9.28 21.31 116.77

从测试结果可以看出,在不同数量的数据集上高斯模糊和均值的组合性能表现相对较好,同时也较为稳定。

Compartir Comentarios

Speed of copying big file from samba server plummets

I was wathcing a movie located on my Home Server today, and all of a sudden, it became stuttering.I suspected it was MPV‘s fault, so I tried another media player, unfortunately, it wasn’t. So, I decided to copy the movie to my local machine first. During the process I noticed something odd:

copying speed plummets

My home network speed is 1Gbps, which means the copying speed should be somewhere around 120Mb. And it was starting at 115Mb, then plummeted quickly.

What was going on, was my newly bought HardDrive is failing? I ssh to my Home Server and copy the exact same file to another HardDrive and it worked ok, so I ruled out the possiblity of faulty HardDrive and went for googling, I tried every means I found and nothing worked!

Seems that I had to figure out the solution on my own, so I ran iotop when copying.

iotop screenshot

Well, that didn’t make any sense to me because my HardDrive is Mechanical, concurrent reads force magnetic arm go back and forth leads to a lower throughput. So I decided to dial it down and see if it solves the problem. I found this setting after some diggings:

1
2
[global]
aio max threads = 1

After restarting smb service, I got these:

copying speed after

iotop after

Finally, the problem was solved. Not sure it’s a bug or feature…

Compartir Comentarios

用ffmpeg修复录屏视频闪烁的问题

缘起

compton 是 X11 下给程序加透明背景的 compositor。前阵子,为了得到一个透明背景模糊的效果,我使用了 compton 的一个 dual_kawase 的分支。虽然平时使用时感觉不出来,但在录屏出来的视频则会不停地闪烁。这样的视频看完估计眼睛就得废掉!

寻找问题

逐帧研究发现,大概每秒都会有一帧只录到了背景,而本应有的 terminal/chrome 啥的都神奇地消失不见了。估计大概是模糊背景的性能原因,导致有的帧没能在限定的时间内完成渲染。

解决问题

事已至此,由于是会议,重新录显然是不可能的。幸好正常帧和出错帧的特征都比较明显:

  1. 出错帧背景都比较明亮
  2. 而正常帧都有一个 terminal 在运行,色彩则比较暗

结论是,我只要在图像上取一个特定点,判定它的亮度,若太明亮则用上一帧代替当前帧,这样就可以基本解决闪烁的问题了。

实现

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
import subprocess as sp
import signal

X = 1500
Y = 450
WIDTH = 1920
HEIGHT = 1080
LENGTH = WIDTH*HEIGHT*3
OFFSET = (WIDTH*(Y-1)+X)*3
FFMPEG_BIN = 'ffmpeg'

# 读取视频解压成图片流
in_cmd = [FFMPEG_BIN,
'-i', 'zoom_0.mp4',
'-f', 'image2pipe', # 输出成图片格式
'-pix_fmt', 'rgb24',
'-vcodec','rawvideo', '-']
in_pipe = sp.Popen(in_cmd, stdout=sp.PIPE, stdin=sp.PIPE, bufsize=LENGTH*10)

# 输出到输出流进行压缩
out_cmd = [FFMPEG_BIN,
'-f', 'rawvideo',
'-pix_fmt', 'rgb24',
'-s', '%dx%d' % (WIDTH, HEIGHT), # 由于是图片流,需要显式指定尺寸
'-r', '25',
'-y',
'-an',
'-i', '-',
'output.mp4']
out_pipe = sp.Popen(out_cmd, stdout=sp.PIPE, stdin=sp.PIPE, bufsize=LENGTH*10)

# 处理逻辑
i = 0
prev_raw = None
while True:
raw_image = in_pipe.stdout.read(LENGTH)
if not raw_image:
break
r = raw_image[OFFSET]
if r > 80:
raw_image = prev_raw
out_pipe.stdin.write(raw_image)
in_pipe.stdout.flush()
prev_raw = raw_image
out_pipe.send_signal(signal.SIGINT)
Compartir Comentarios

threading vs async

前言

本文旨在探讨单一线程中线程模型和异步模型在完成类似的功能下的区别, 而由于进程管理(如gunicorn/pm2等)和架构上的优化(如读写分离等)属于进程外的范围, 对两都是适用的, 因此不在讨论之列. 请知悉.

多任务

我们常常会遇到一种情况: 我们开发的系统除了它的首要任务外,还常常伴随着有一些周期性的任务需要随之运行. 比如说, 到午夜的时候清空一下临时文件, 或者定时地发心跳包等等. 这些次要任务与主要任务之间相互不能阻塞, 如果它们之间互不相关独立运行的话, 解决起来也很简单:

  1. 跑一个独立的脚本或进程定时执行(不作深入讨论)
  2. 起一个相对独立的线程, 循环睡眠执行
  3. 异步事件定时执行

但有的时候, 情况可能会变得复杂. 比如说,需要在主任务里面控制这些定时任务具体是不是执行,或说动态地去修改执行间隔等. 对于独立的进程,可能就需要加一些机制进行去实现进程通信. 特别如果这些次要任务数量较多的话, 进程的通信和管理将变得很复杂. 这时,线程管理起来相对就方便一些, 通信也相对容易, 然而代价是需要考虑线程安全的问题. 线程安全又是一个不好处理的问题

共享计数器篇

请看以下基于线程模型的实现代码(c实现)

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
#include <stdio.h>
#include <pthread.h>
#include <time.h>

#define THREAD_TOTAL 100
#define NSEC_PER_SEC 1000000000.0

int counter = 0;

void *incr(void *args) {
for (int i = 0; i < 1000000; i++)
counter++;
}

pthread_mutex_t lock;
void *incr_safe(void *args) {
pthread_mutex_lock(&lock);
for (int i = 0; i < 1000000; i++)
counter++;
pthread_mutex_unlock(&lock);
}

void main() {
struct timespec start, end; // clock_t 计时不准,换timespec
clock_gettime(CLOCK_REALTIME, &start);
counter = 0;
pthread_t thread_ids[THREAD_TOTAL];
for (int i = 0; i < THREAD_TOTAL; i++) {
pthread_t thread_id;
#ifdef UNSAFE
pthread_create(&thread_id, NULL, incr, NULL);
#else
pthread_create(&thread_id, NULL, incr_safe, NULL);
#endif
thread_ids[i] = thread_id;
}
for (int i = 0; i < THREAD_TOTAL; i++) {
pthread_join(thread_ids[i], NULL);
}
clock_gettime(CLOCK_REALTIME, &end);
double elapsed = end.tv_sec-start.tv_sec + (end.tv_nsec-start.tv_nsec)/NSEC_PER_SEC;
printf("Counter: %d, Elapsed: %f\n", counter, elapsed);
}

编译得到不安全和安全的可执行版本:

1
2
$ gcc -o bin/counter-unsafe -lpthread -DUNSAFE 01-counter.c
$ gcc -o bin/counter-safe -lpthread 01-counter.c

输出参考:
counter-unsafe

1
Counter: 6296382  Elapsed: 0.323539

counter-safe

1
Counter: 100000000  Elapsed: 0.240659

以上的命令执行多次,大概可以看得出来:

  1. 不加锁的情况下,速度稍慢,数值不准确
  2. 加锁的情况下,速度稍快,数值准确

不安全版本的数值错误,是因为语句counter += 1会被拆成 读值,做加法,存回 三条指令(也可能不止),若操作系统在这几步中间切到别的线程上,必然会导致不符合预期的结果。

虽然加锁版本比不加锁还要快这一点很违反直觉,但这个是事实。事实上, 锁竞争的并没有相象中那么激烈(这个后面还会有例子说明),安全版本之所以会更快,是因为它在获得了锁之后,进行了全部的操作再释放锁给下一个线程。也即它保证了同一时间只有一个线程在执行,避免了很多线程的切换。在线程切换的过程中,操作系统需要先将当前上下文压栈,切回来时再弹栈。单一切换的损耗可能比锁竞争小, 但在数量差别具大的情况下切换带来的开销要比锁竞争明显得多。这个事实,是GIL的理论基础,python社区周期性地会出现 去除GIL 的动议和尝试,至今没人成功, 这里有一篇 python 的 wiki提到相关的问题(注意看Speed那一项)

来看看异步模型做类似的事情是怎么弄的(node实现):

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
PROMISE_TOTAL = 100

let counter = 0;

async function incr() {
for (let i = 0; i < 1000000; i++)
counter++;
}

async function count() {
counter = 0;
const ps = [];
for (let i = 0; i < PROMISE_TOTAL; i++) {
ps.push(incr());
}
await Promise.all(ps);
}

(async () => {
const start = process.hrtime();
await count();
const elapsed = process.hrtime(start);
const seconds = elapsed[0] + elapsed[1]/1000000000;
console.log('Counter: %d, Elapsed: %d', counter, seconds);
})();

输出参考:

1
Counter: 100000000,  Elapsed: 0.203944253

居然比c还快那么一点点。

附上基于 python 的线程和异步实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import asyncio
import time
import sys
from threading import Thread, Lock

THREAD_TOTAL = 100
counter = 0

def incr():
global counter
for i in range(1000000):
counter += 1

async def incr_async():
global counter
# 若没有这个for,在py3中是可以得到正确结果的
for i in range(1000000):
counter += 1

lock = Lock()
def incr_safe():
global counter, lock
lock.acquire()
for i in range(1000000):
counter += 1
lock.release()

def count(target):
threads = []
for i in range(THREAD_TOTAL):
thread = Thread(target=target)
threads.append(thread)

for thread in threads:
thread.start()

for thread in threads:
thread.join()

async def count_async():
coros = []
for _ in range(THREAD_TOTAL):
coros.append(incr_async())
await asyncio.gather(*coros);

start = time.time()
if len(sys.argv) > 1:
if sys.argv[1] == 'UNSAFE':
count(incr)
elif sys.argv[1] == 'ASYNC':
asyncio.run(count_async())
else:
count(incr_safe)
end = time.time()
print('Counter: %d, Elapsed: %f' % (counter, end-start))

通过执行以下三个命令,我们可以得到安全,安全非和异步的计数结果和耗时:

1
2
3
python 02-counter.py
python 02-counter.py UNSAFE
python 02-counter.py ASYNC

大致分别为:

1
2
3
Counter: 100000000,  Elapsed: 6.041980
Counter: 35023466, Elapsed: 6.367411
Counter: 100000000, Elapsed: 5.56256

ps:

  1. 这里要特别提一下在py3中, GIL的释放策略由原来py2的每N条”指令”释放一次, 变成了每隔一定时间(默认5ms)释放一次. 此时若累加操作完成太快会导致线程看起来似乎是安全的, 因此测试中特别地对累加操作连续执行百万次.
  2. python本身的执行效率不高, 不难推测出大量的时间花费在了循环累加上, 把这部时间去掉的话, 线程模型和异步模型的执行时间比例会大幅上升.

回到正题,以上几段代码。都是在模拟有多个并发请求,对同一共享变量的进行读写的情况。
相对于多线程, 异步模型完成同样的事情, 不但效率更好,写起来也更简练,需要操心的事情更少。对于共享变量,我们可以在任意的地方,毫无顾忌地任意读写

缓存加载篇

我们用一个例子来说明一下异步编程在功能上给我们带来了哪些便利. 假定我们的系统用户量挺大, 有些数据加载要花较长的时间, 很自然我们会使用到缓存. 缓存是一种看起来简单用起来其实挺麻烦的东西. 就比如说冷加载吧, 假定现在系统刚初始化或者缓存刚被清空. 这时候当用户访问到某个数据时, 我们就把它加载到缓存里再返回, 后续用户访问就直接从缓存读出返回. 但我们知道缓存加载需要一定时间, 在开始加载到加载完成的这一段时间内, 有其它用户也在请求呢? 或者更极端点, 这个时候是访问高峰, 缓存过期了. 此时有几百个用户同时在请求呢? 理想处理方式是, 保证整个过程中只有一个人去加载缓存, 其他人等到它加载完了直接使用缓存就行了. 对应到线程模型, 那即是保证只有一个线程在加载, 其它线程都在等待. 这是非常典型的线程同步问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#include <assert.h>
#include <time.h>

#define THREAD_TOTAL 1000
#define NSEC_PER_SEC 1000000000.0

short is_loading = 0;
int cache = 0;
int counter = 0;
pthread_mutex_t lock;


int load_from_db() {
sleep(1);
counter++;
return 123;
}

void* get_customer_detail_safe(void *args) {
if (cache) {
// good to go
assert(cache == 123);
} else if (is_loading) {
// loop until cache is ready
while (is_loading) {
usleep(100);
}
// good to go
assert(cache == 123);
} else {
printf("try to obtain the lock\n");
// lock
#ifdef BLOCKING
pthread_mutex_lock(&lock);
if (!cache) {
is_loading = 1;
cache = load_from_db();
is_loading = 0;
}
pthread_mutex_unlock(&lock);
#else
if (pthread_mutex_trylock(&lock) == 0) {
is_loading = 1;
cache = load_from_db();
is_loading = 0;
pthread_mutex_unlock(&lock);
} else {
// loop until cache is ready
while (is_loading) {
usleep(100);
}
}
#endif
// good to go
assert(cache == 123);
}
}

void* get_customer_detail_unsafe(void *args) {
if (cache) {
// good to go
} else {
cache = load_from_db();
// good to go
}
}

int main() {
struct timespec start, end;
clock_gettime(CLOCK_REALTIME, &start);
pthread_t thread_ids[THREAD_TOTAL];
for (int i = 0; i < THREAD_TOTAL; i++) {
pthread_t thread_id;
#ifdef UNSAFE
pthread_create(&thread_id, NULL, get_customer_detail_unsafe, NULL);
#else
pthread_create(&thread_id, NULL, get_customer_detail_safe, NULL);
#endif
thread_ids[i] = thread_id;
}
for (int i = 0; i < THREAD_TOTAL; i++) {
pthread_join(thread_ids[i], NULL);
}
clock_gettime(CLOCK_REALTIME, &end);
double elapsed = end.tv_sec-start.tv_sec + (end.tv_nsec-start.tv_nsec)/NSEC_PER_SEC;
printf("Counter: %d , Elapsed: %f\n", counter, elapsed);
}

通过以下命令编译分别得到非安全,安全(使用trylock)和安全(使用阻塞锁)三个版本:

1
2
3
gcc -o bin/cache-unsafe -lpthread -DUNSAFE 04-cache.c
gcc -o bin/cache-safe -lpthread 04-cache.c
gcc -o bin/cache-safe-blocking -lpthread -DBLOCKING 04-cache.c

非安全的参考输出:

1
Counter: 996  , Elapsed: 1.018113

安全的trylock:

1
2
3
4
5
6
try to obtain the lock
try to obtain the lock
try to obtain the lock
try to obtain the lock
try to obtain the lock
Counter: 1 , Elapsed: 1.008818

安全的阻塞锁:

1
2
3
try to obtain the lock
try to obtain the lock
Counter: 1 , Elapsed: 1.008515

其中,安全的两个版本可以看出,锁竞争的情况并不严重。因此,使用trylock和lock不会有明显的差异

回到正题,在非安全的版本中,缓存被加载了996次!这若发现在生产环境,很容易由于某些节点瞬时负载过大,从而引发连锁反应。因此,保证某时耗时很长的操作在同一时间只被加载一次还是很有必要的。
我们来看看异步模型下可以怎么解决:

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
let counter = 0;

function loadFromDb() {
return new Promise(resolve => {
counter++;
setTimeout(() => resolve(123), 1000);
});
}

let loading = null;
let cache = null;
async function getCustomerDetailSafe() {
if (cache) {
// good to go
} else {
loading = loading || loadFromDb().then(() => {
loading = null;
});
cache = await loading;
// good to go
}
}

async function getCustomerDetailUnsafe() {
if (cache) {
// good to go
} else {
cache = await loadFromDb();
// good to go
}
}

(async() => {
const start = process.hrtime();
const requests = [];
for (let i = 0; i < 1000; i++) {
requests.push(getCustomerDetailSafe());
//requests.push(getCustomerDetailUnsafe());
}

await Promise.all(requests);
const elapsed = process.hrtime(start);
const seconds = elapsed[0] + elapsed[1]/1000000000;
console.log('Counter: %d, Elapsed: %d', counter, seconds);
})();

请允许我只贴安全版本的参考结果:

1
Counter: 1,  Elapsed: 1.005952907

经过N次重复实验,结果总是会比多线程的快一点点
注意,这个结果是很不得了的。因为c的执行速度比node快很多。有兴趣的同学可以试一下附带的三个指数级(n每加1,计算量翻一番) fib 实现,测一下它们间的速度差别。
异步模型能做到这一点,是因为所有你编写的代码,都是在一个线程里面执行的。也就不存在线程安全的问题,上面的loading会且只会被赋值一次。
反观线程模型,即使在这个简单的, 只有一个锁的情况, 在实现时也要小心翼翼. 若是情况再复杂些, 再多几个锁还需要操心死锁的问题. 更别提多人协作时情况会变得多么不可控了.
总的来说, 异步模型对于以往一些只能由线程来完成的功能非常地方便好用, 而且由于少了线程切换和锁竞争的开销, 并且速度往往有肉眼可见的提高

ps: 以上实现的是一种单一进程下响应式的缓存策略, 可以在进程内, 进程外, 任意的时间, 任意的方式清空缓存, 所有在缓存清空后访问的用户都能看到最新的数据. 我见过一些系统, 采用独立进程定时更新缓存的策略, 用户在使用的时候往往需要等待缓存更新, 当然也不能随便地通过管理界面去清缓存了. 这些都只是策略问题, 与编程模型无关.

线程的Flask 与 异步aiohttp

以下是参照 Flask 官方的代码:

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
import time
import redis
from flask import Flask
from threading import Lock
import logging
log = logging.getLogger('werkzeug')
log.setLevel(logging.ERROR)

app = Flask(__name__)

lock = Lock()
counter = 1

@app.route('/')
def index():
global counter
lock.acquire()
counter += 1
lock.release()
return str(counter)

@app.route('/slow')
def slow():
time.sleep(1)
return 'ok'

我们先来验证一下,Flask是多线程的,注意上面/slow,每个请求要花1秒。那我们并发的请求100个应该要等100s

1
$ ab -n 100 -c 100 http://localhost:5000/slow

截取部分结果:

1
Time taken for tests:   2.217 seconds

总共花了2.217秒,说明请求之间不会相互阻塞,可以确认Flask当前是运行在多线程的状态下。

aiohttp 也是依照官方文档给的例子稍微调整:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from aiohttp import web

counter = 0

async def index(request):
global counter
counter += 1
return web.Response(text=str(counter))


app = web.Application()
app.add_routes([web.get('/', index)])

web.run_app(app, port=5000)

好,功能一样,我们来看看输出结果
Flask

1
2
3
4
5
6
7
8
9
10
Concurrency Level:      300
Time taken for tests: 0.687 seconds
Complete requests: 1000
Failed requests: 0
Total transferred: 157000 bytes
HTML transferred: 4000 bytes
Requests per second: 1456.00 [#/sec] (mean)
Time per request: 206.044 [ms] (mean)
Time per request: 0.687 [ms] (mean, across all concurrent requests)
Transfer rate: 223.23 [Kbytes/sec] received

aiohttp

1
2
3
4
5
6
7
8
9
10
Concurrency Level:      300
Time taken for tests: 0.269 seconds
Complete requests: 1000
Failed requests: 0
Total transferred: 154000 bytes
HTML transferred: 4000 bytes
Requests per second: 3717.90 [#/sec] (mean)
Time per request: 80.691 [ms] (mean)
Time per request: 0.269 [ms] (mean, across all concurrent requests)
Transfer rate: 559.14 [Kbytes/sec] received

也许Flask不是线程模型的最佳实现,但aiohttp也一样不是最优的(比如sanic)。也许Flask还有许多优化的空间,在上生产环境还需要一阵敲打才能发挥出实力。但真这样话,那也只能是缺点吧? 要知道 aiohttp 上生产环境并不需要特别的配置,那么问题来了:

  1. 在单进程的情况下可能配置出一倍效率来吗?
  2. 开发环境与生产环境不一致管理起来麻烦吗?
  3. 多线程程序好调吗?

局限性

一件东西有它的长处, 必然有它的短处, 毕竟异步模型又不是马克思主义. 我们来讲讲它的局限性. 也许有的同学可能已经注意到, 我刚才讲的例子都是属于I/O密集性. 基本上都没有什么运算. 如果你的系统是CPU密集型的话, 异步模型就不是那么适用了. 我们来看一下指数级的斐波那契数列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
start calculating
n = 44, calculation took 7.646799437
hello
hello
hello

/* 输出大概如下:
start calculating
hello
hello
hello
hello
hello
n = 44, calculation took: 4.572308
hello
*/

node 版

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
const fib = (n) => {
if (n < 2) return n;
return fib(n - 1) + fib(n - 2);
};

setInterval(() => {
console.log('hello');
}, 1000);

(async() => {
const n = 44;
const start = process.hrtime();
console.log('start calculating');
fib(n);
const elapsed = process.hrtime(start);
const seconds = elapsed[0] + elapsed[1]/1000000000;
console.log('n = %d, calculation took %d', n, seconds);
})();


/* 输出大概如下:
start calculating
n = 44, calculation took 7.646799437
hello
hello
hello
...
*/

异步的事件循环机实际上相当于一个队列, 在当前代码段(请想像成以await关键字为分隔的分段)未执行完之前, 后面的代码段只能等待. 而线程由操作系统调度, 独立性是有保障的. 所以异步模型并不适合用来做有大量计算的事情. 当然这样的问题也好处理,只要将计算密集的逻辑分离出去(采用线程,进程,网络)把它变成I/O就行了.
异步还有另外一个问题是由于没有多线程,导致在多核机器上只能通过多进程来充分利用CPU。不过,新版的 node 已经开始支持 WorkerThread 了。至于python线程, 呵呵!

结论

通过以上的对比, 可以看出来对于并发任务来讲, 异步模型实现更为方便直观, 效率上也更优秀. 考虑到绝大多数系统都不是CPU密集型的, 掌握异步模型非常重要. 而在使用时也需要注意它的执行特点, 它更适合用于少量计算, 大量I/O, 大量吞吐的场景. 若中间有大量的计算, 将会阻塞整个进程, 需要注意将大量计算分离出去.

相关代码下载

https://github.com/klesh/threading-vs-async

Compartir Comentarios

How to make hexo-browsersync working by solving hexo-server rendering incomplete page

What is hexo-server and hexo-browsersync?

  • hexo-server 可以让你实时在本地预览你的hexo博客, 而不必编译整个网站, 对于编写博文和调试hexo本身的插件主题是很有用的
  • hexo-browsersync 只有hexo-server的话,每次改完都得手动刷新一下浏览器, 这很不科学. browsersync 可以自动在你保存文件的时候自动化地刷新浏览器

What is the problem?

实时预览/自动刷新, 这些都很普通. 所以我的期望也很普通, 装上能用就行. 奇怪的是, 有时候写着写着, 它突然间就不灵光了!
更神奇的是, 有时候删除掉一些文本后, 它又突然能 work 了. 追踪问题的过程太琐碎就不细说了, 总的来讲, 这玩意返回的 Content-Length 是错的, 比实际内容短,
导致浏览器将后面(也可能服务器也根本不输出了?)的内容被丢弃. 看了 hexo-server 的 github ,竟然没人提这个 issue? 好吧,若不是用的人少,那即是我太啰嗦写文太长了.

How to solve

当然, 关键还是怎么解这个问题. 我在 hexo/hexo-server 这几个 repos 翻了半天, 竟然都没有发现有 Content-Length 的设定.
有点神奇, 就在万般无奈的情况下, 看到 hexo-server 的 config 里面有一项叫做 compress . 一般服务器对内容压缩都会使用流
式压缩, 即不会使用 Content-Length 预设页面的长度. 经过测试, 确实可以解决代码被截断的问题:

_config.yml 加入:

1
2
3
4
5
6
7
8
9
server:
port: 5000
log: false
ip: 127.0.0.1
compress: true
header: true
serveStatic:
extensions:
- html

Compartir Comentarios

How to build wxWidget app bundle in Mac OS X

今天终于把 fu 写完了。于是便愉快地开始了打包工作,我的目标也很简单,像多数 mac app 一样,生成一个 dmg 文件,里面一个 fu.app 和一个 Applications 的链接就完了。却是不曾想,这么简单的事情费了整整一天的时间!

吐糟一下 Apple

本来想加多一个勾选框给用户可以选择是否自动启动,但 Apple Developer 的文档真是令人惊奇地差劲。全部都不提供 code sample ,光把那些长长的方法和字段名列一下就指望开发者能明白。我实现在不想去摆弄 objective c 这门如此罗索的语言,只好求助 google,结果

Compartir Comentarios

升级 Ubuntu Kernel 至 4.9 - 启用 BBR 提高梯子效率

前言

BBR 具体的原理可以参照 知乎上的文章 。对于技术工种的人们来讲,梯子这种日常使用频率极高的工具,自然是一分快十分好,果断要升级一下!

选择 kernel

官方的 apt 包提供的内核以稳定为主,必然不会是最新的。要升级到 4.9 的 Kernel,需要手动操作。

首先是挑选需要的版本,官方 upstream kernel 列表:http://kernel.ubuntu.com/~kernel-ppa/mainline/ , 拉到下面就可以看到 4.9 了,点进去。

我的是 64 位系统,对应就要下载这个节点的文件:

1
2
3
4
5
6
Build for amd64 succeeded (see BUILD.LOG.amd64):
linux-headers-4.9.0-040900_4.9.0-040900.201612111631_all.deb
linux-headers-4.9.0-040900-generic_4.9.0-040900.201612111631_amd64.deb
linux-headers-4.9.0-040900-lowlatency_4.9.0-040900.201612111631_amd64.deb
linux-image-4.9.0-040900-generic_4.9.0-040900.201612111631_amd64.deb
linux-image-4.9.0-040900-lowlatency_4.9.0-040900.201612111631_amd64.deb

内核一般由 2 部份组成,linux-headers 是内核的头文件,当你编译的程序需要引用内核时就靠它了; linux-image 开头的文件就是内核编译后的镜像,是实际可运行的部分。genericlowlatency 则是针对不同的使用场景进行调优的版本。

稍微查了一下 lowlatency 的信息。它比较适用实时性要求较高的场景,比如说录音之类的使用场景,所谓有得必说失,代价可能是稳定性和吞吐量(这个就跟 BBR 的原理差不多,牺牲带宽换取速度,当然这里的吞吐量会不会也影响到网络就不知道咯)。我这边由于网络出口本身也就不怎么样,估计就是有差别也很难测得出来。总之,若你不知道选哪个的话,就用 generic 版本的。

安装配置

上面有 3 个 headers 和 2 个 image ,其中 headers 中的 allgenericlowlatency 都需要的依赖。也就是一共要安装 3 个包。接下来,把 3 个文件都 wget 到本地:

1
2
3
4
wget \
http://kernel.ubuntu.com/~kernel-ppa/mainline/v4.9/linux-headers-4.9.0-040900_4.9.0-040900.201612111631_all.deb \
http://kernel.ubuntu.com/~kernel-ppa/mainline/v4.9/linux-headers-4.9.0-040900-generic_4.9.0-040900.201612111631_amd64.deb \
http://kernel.ubuntu.com/~kernel-ppa/mainline/v4.9/linux-image-4.9.0-040900-generic_4.9.0-040900.201612111631_amd64.deb

安装:

1
dpkg -i linux-*

配置使用新内核:

1
2
update-grub
reboot

正常启动,删除旧内核,通过以下的命令列出所有的 headersimage

1
2
dpkg -l | grep linux-headers | awk '{print $2}'
dpkg -l | grep linux-image | awk '{print $2}'

然后把旧的 headers 和 image 一个个删除掉就行了:

1
dpkg purge xxxx

开始配置 BBR

1
2
3
4
echo "net.core.default_qdisc=fq" >> /etc/sysctl.conf
echo "net.ipv4.tcp_congestion_control=bbr" >> /etc/sysctl.conf
sysctl -p
reboot

测试 BBR 是否是已经开启(若有输出即证明 ok 了)

1
lsmod | grep bbr

个人使用感受

看油管确实流畅许多。今天看 Primitive Technology 1080p 时,只在开头缓冲了一下,后面就很流畅了。

Compartir Comentarios

在 Xcode 8 中创建 wxWidgets 的工程

wxWidgets 的文档太老了,相应的指引根本无法使用。经过一番摸索,终于找到了在 Xcode 中创建 wxWidgets 的工程。

前提条件

Homebrew

1
$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

wxWidgets

1
$ brew install wxwidgets

开始创建工程

  1. 打开 Xcode -> “Create a new Xcode project”
    Create a new Xcode project

    注意要选择 “Cocoa Application”。我一开始选了 “Command Line Tool” ,虽然也能正常跑出来界面,但生成的只是一个可执行文件,而不是 Application Bundle 。这样一来就无法定制程序的图标和一些其它的行为。

  2. 语言选 Objective C ,其它的勾全部清空。
    Set up project

  1. Build Settings -> Other Linker Flags

    打开 Terminal 输入

    1
    wx-config --libs

    将输出的内容添加到:
    Other Linker Flags

  2. Build Settings -> Other C++ Flags

    打开 Terminal 输入

    1
    wx-config --cxxflags

    将输出的内容添加到 “Ohter C++ Flags”

  3. 把没用的文件删除:
    Delete useless files
    其中 Assets.xcassets 是用来放图标的,要留着。MainMenu.xib 是程序菜单,不需要可以删除掉。

  4. wxWidgets 的 Hello world 测试一下。
    new file
    新建一个 main.cpp ,粘贴:

    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
    // wxWidgets "Hello world" Program
    // For compilers that support precompilation, includes "wx/wx.h".
    #include <wx/wxprec.h>
    #ifndef WX_PRECOMP
    #include <wx/wx.h>
    #endif
    class MyApp: public wxApp
    {
    public:
    virtual bool OnInit();
    };
    class MyFrame: public wxFrame
    {
    public:
    MyFrame(const wxString& title, const wxPoint& pos, const wxSize& size);
    private:
    void OnHello(wxCommandEvent& event);
    void OnExit(wxCommandEvent& event);
    void OnAbout(wxCommandEvent& event);
    wxDECLARE_EVENT_TABLE();
    };
    enum
    {
    ID_Hello = 1
    };
    wxBEGIN_EVENT_TABLE(MyFrame, wxFrame)
    EVT_MENU(ID_Hello, MyFrame::OnHello)
    EVT_MENU(wxID_EXIT, MyFrame::OnExit)
    EVT_MENU(wxID_ABOUT, MyFrame::OnAbout)
    wxEND_EVENT_TABLE()
    wxIMPLEMENT_APP(MyApp);
    bool MyApp::OnInit()
    {
    MyFrame *frame = new MyFrame( "Hello World", wxPoint(50, 50), wxSize(450, 340) );
    frame->Show( true );
    return true;
    }
    MyFrame::MyFrame(const wxString& title, const wxPoint& pos, const wxSize& size)
    : wxFrame(NULL, wxID_ANY, title, pos, size)
    {
    wxMenu *menuFile = new wxMenu;
    menuFile->Append(ID_Hello, "&Hello...\tCtrl-H",
    "Help string shown in status bar for this menu item");
    menuFile->AppendSeparator();
    menuFile->Append(wxID_EXIT);
    wxMenu *menuHelp = new wxMenu;
    menuHelp->Append(wxID_ABOUT);
    wxMenuBar *menuBar = new wxMenuBar;
    menuBar->Append( menuFile, "&File" );
    menuBar->Append( menuHelp, "&Help" );
    SetMenuBar( menuBar );
    CreateStatusBar();
    SetStatusText( "Welcome to wxWidgets!" );
    }
    void MyFrame::OnExit(wxCommandEvent& event)
    {
    Close( true );
    }
    void MyFrame::OnAbout(wxCommandEvent& event)
    {
    wxMessageBox( "This is a wxWidgets' Hello world sample",
    "About Hello World", wxOK | wxICON_INFORMATION );
    }
    void MyFrame::OnHello(wxCommandEvent& event)
    {
    wxLogMessage("Hello world from wxWidgets!");
    }

    7, 点击运行,一切 OK 就可以开始愉快地编程了。

Compartir Comentarios

违法停车时如何避免罚款?

如何正确地违法停车
太有才了!

9gag

Compartir Comentarios

Ubuntu 下配置基于 Nginx/Openresty 自动化 Let's encrypt 证书申请、更新

简述

趁着年底有点时间,把自己的服务器操作系统升级到了 16.04 。Let’s Encrypt 是早就听说过,只是一直没时间搞。前两天听朋友讲到在 nginx/openresty 下有自动化的 lua 脚本可以实现自动化的申请和证书更新,感觉非常有意思,顺便折腾了一把。

过程

Openresty

Openresty 是在 nginx core 的基础上集成了 LuaJIT 和许多第三方的 nginx 模块。除了 nginx 本身具备的功能外,还可以用来做 web application,web service。利用 lua 可以直接在 Openresty 里面构建动态服务。目前官方只提供 RPM 的预编译包,其它操作系统需要自行编译。 官方的安装说明 简明易懂,直接照猫画虎即可。

接下来要配置 systemd ,让 Openresty 可以自动启动。

1
$ sudo vim /etc/systemd/system/nginx.service

nginx.service

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[Unit]
Description=The nginx HTTP and reverse proxy server
After=syslog.target network.target remote-fs.target nss-lookup.target

[Service]
Type=forking
PIDFile=/usr/local/openresty/nginx/logs/nginx.pid
ExecStartPre=/usr/local/openresty/nginx/sbin/nginx -t
ExecStart=/usr/local/openresty/nginx/sbin/nginx
ExecReload=/bin/kill -s HUP $MAINPID
ExecStop=/bin/kill -s QUIT $MAINPID
PrivateTmp=true

[Install]
WantedBy=multi-user.target

启用:

1
2
$ sudo systemctl enable nginx
$ sudo systemctl start nginx

附上几个调试技巧:

  • 通过 systemctl status nginx 可以看到 nginx 是不是正常启动了。若是失败这里也会输出用的日志信息,可以按左右键对界面进行横向滚动
  • 需要看到更多日志可以使用 journalctl -b _PID=上一步中输出的PID 查看更多的信息。
  • 通过 curl http://localhost 确认 nginx 已经可以正常工作。

LuaRocks

这个 lua 的包管理器,相当于 node.js 的 npm 。安装的方法 点这里 。 上面推荐安装的版本是 2.0.13 。但我装的是当时最新的 2.4.2 ,也许是这个原因导致我后面配置 lua-resty-auto-ssl 的时候踩了坑?

lua-resty-auto-ssl

装完 LuaRocks 之后,首先要配置一下 PATH,在 ~/.profile 中加入:

1
export PATH=/usr/local/openresty/luajit/bin:/usr/local/openresty/bin:/usr/local/openresty/nginx/sbin:$PATH


1
source ~/.profile

然后就可以使用它来安装 lua-resty-auto-ssl 了:

1
2
3
4
5
6
$ sudo luarocks install lua-resty-auto-ssl

# Create /etc/resty-auto-ssl and make sure it's writable by whichever user your
# nginx workers run as (in this example, "www-data").
$ sudo mkdir /etc/resty-auto-ssl
$ sudo chown www-data /etc/resty-auto-ssl

修正 lua-resty-auto-ssl 的脚本权限问题

1
2
$ sudo chmod +x /usr/local/openresty/luajit/share/lua/5.1/resty/auto-ssl/shell/*
$ sudo chmod +x /usr/local/openresty/luajit/share/lua/5.1/resty/auto-ssl/vendor/*

这就是上面我提到的坑,也许是 LuaRocks 的问题,也许不是。如果你找到了原因,欢迎 comment 给我,谢谢哈。

在 Openresty 中配置 lua-resty-auto-ssl

nginx 的 ssl 站点需要先指定一个静态的 ssl_certificate, 否则会报错,因此需要生成一个自签的证书,骗过 nginx 让它顺利启动之后,再由 lua-resty-auto-ssl 返回动态的证书。

1
2
3
4
$ sudo openssl req -new -newkey rsa:2048 -days 3650 -nodes -x509 \
-subj '/CN=sni-support-required-for-valid-ssl' \
-keyout /etc/ssl/resty-auto-ssl-fallback.key \
-out /etc/ssl/resty-auto-ssl-fallback.crt

我习惯于将全局配置就放到 nginx.conf 里面,每个虚拟主机、网站或说应用独立一个配置文件 example.com.vh.conf 这样。

nginx 的全局配置 /usr/local/openresty/nginx/conf/nginx.conf

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
http {
# 配置 lua-resty-auto-ssl 的全局选项,并启动服务
lua_shared_dict auto_ssl 1m;
resolver 8.8.8.8;

init_by_lua_block {
auto_ssl = (require "resty.auto-ssl").new()

# 这里你可以限定只给那些域名启用 auto ssl
auto_ssl:set("allow_domain", function(domain)
return true
end)

auto_ssl:init()
}

init_worker_by_lua_block {
auto_ssl:init_worker()
}

server {
listen 127.0.0.1:8999;
location / {
content_by_lua_block {
auto_ssl:hook_server()
}
}
}

include /path/to/example.com.vh.conf;
}

example.com.vh.conf

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
server {
listen 80;
server_name example.com;

# Let's Encrypt 需要验证你对域名的控制权,这个就是用来应答的.
location /.well-known/acme-challenge/ {
content_by_lua_block {
auto_ssl:challenge_server()
}
}
}

server {
listen 443 ssl;
server_name example.com;

# lua-resty-auto-ssl 的精华部份,若当前还没有证书或已过期则自动申请,然后返回,证书有效就直接返回
ssl_certificate_by_lua_block {
auto_ssl:ssl_certificate()
}

# 这里配上之前我们生成的自签名证书,否则会报错
ssl_certificate /etc/ssl/resty-auto-ssl-fallback.crt;
ssl_certificate_key /etc/ssl/resty-auto-ssl-fallback.key;
}

我主要是演示这些配置放在哪些地方,参数的意义可以在 lua-resty-auto-ssl 主页上查询。

以上配置完成重启 nginx ,接着就可以看看 auto ssl 是否能正常工作了:

1
2
$ sudo systemctl restart nginx
$ curl https://example.com

若有错误,可以查看 nginx 的 error log, lua-resty-auto-ssl 会将错误信息输出到这上面。

总结

虽然早有 CertBot 这样的自动化工具,但 lua-resty-auto-ssl 显然是更加地方便,在新建站点的时候多加几行即可实现全自动化的 https 。

Compartir Comentarios