0%

传统的错误处理方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function thisThrows() {
throw new Error("Thrown from thisThrows()");
}

try {
thisThrows();
} catch (e) {
console.error(e);
} finally {
console.log('We do cleanup here');
}

// Output:
// Error: Thrown from thisThrows()
// ...stacktrace
// We do cleanup here

非常简单,不过多赘述

一个包装在Async中的Try Catch

现在我们修改这个程序,把thisThrows()函数标记为async。此时抛出的错误,实际上相当于抛出一个Reject。一个Async函数,总是返回一个Promise

  • 当没有定义返回语句的时候,函数运行结束后实际返回的是Promise,相当于return Promise.Resolve()
  • 当有定义返回语句的时候,相当于返回了一个带有值的Promise,相当于return Promise.Resolve('My return String')
  • 当抛出错误的时候,相当于return Promise.Reject(error)

看下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
async function thisThrows() {
throw new Error("Thrown from thisThrows()");
}

try {
thisThrows();
} catch (e) {
console.error(e);
} finally {
console.log('We do cleanup here');
}

// output:
// We do cleanup here
// UnhandledPromiseRejectionWarning: Error: Thrown from thisThrows()

thisThrows返回一个Reject,所以我们使用常规的try...catch无法正常的捕捉到错误。

thisThrws标记为async,所以我们调用的时候,代码不会等待,finally块会先执行,所以这里无法捕捉到错误。

有两个方式可以解决这个问题:

第一个解决方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
async function thisThrows() {
throw new Error("Thrown from thisThrows()");
}

async function run() {
try {
await thisThrows();
} catch (e) {
console.error(e);
} finally {
console.log('We do cleanup here');
}
}

run();

// Output:
// Error: Thrown from thisThrows()
// ...stacktrace
// We do cleanup here

第二个解决方式

1
2
3
4
5
6
7
8
9
10
11
12
async function thisThrows() {
throw new Error("Thrown from thisThrows()");
}

thisThrows()
.catch(console.error)
.then(() => console.log('We do cleanup here'));

// Output:
// Error: Thrown from thisThrows()
// ...stacktrace
// We do cleanup here

async/await的方式相对来说更容易理解。

注意点

从async函数中返回

考虑下下面代码会输出什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
async function thisThrows() {
throw new Error("Thrown from thisThrows()");
}

async function myFunctionThatCatches() {
try {
return thisThrows();
} catch (e) {
console.error(e);
} finally {
console.log('We do cleanup here');
}
return "Nothing found";
}

async function run() {
const myValue = await myFunctionThatCatches();
console.log(myValue);
}

run();

我们可能期待输出

1
2
We do cleanup here
Nothing Found

实际输出一个UnhandledPromiseRejection

我们分析下

  • thisThrows() 是异步方法;
  • 异步方法中抛出了一个错误,实际返回的是Promise.Reject
  • myFunctionThatCatches中返回了这个Promise.Reject
  • 外部是以await标记的,发现是一个Reject的Prmoise,所以抛出unlandled promise rejection

我们可以在返回中增加await解决这个问题(第七行)

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
async function thisThrows() {
throw new Error("Thrown from thisThrows()");
}

async function myFunctionThatCatches() {
try {
return await thisThrows(); // <-- Notice we added here the "await" keyword.
} catch (e) {
console.error(e);
} finally {
console.log('We do cleanup here');
}
return "Nothing found";
}

async function run() {
const myValue = await myFunctionThatCatches();
console.log(myValue);
}

run();

// Outptut:
// Error: Thrown from thisThrows()
// ...stacktrace
// We do cleanup here
// Nothing found

重置stack trace

在代码中,经常会看到有人捕获错误并将其包装在一个新的错误中,就像下面的代码片段中一样

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
function thisThrows() {
throw new Error("Thrown from thisThrows()");
}

function myFunctionThatCatches() {
try {
return thisThrows();
} catch (e) {
throw new TypeError(e.message);
} finally {
console.log('We do cleanup here');
}
}

async function run() {
try {
await myFunctionThatCatches();
} catch (e) {
console.error(e);
}
}

run();

// Outputs:
// We do cleanup here
// TypeError: Error: Thrown from thisThrows()
// at myFunctionThatCatches (/repo/error_stacktrace_1.js:9:15) <-- Error points to our try catch block
// at run (/repo/error_stacktrace_1.js:17:15)
// at Object.<anonymous> (/repo/error_stacktrace_1.js:23:1)

注意我们的堆栈跟踪仅从我们捕获原始异常的地方开始。当我们在 2 行创建错误并在 9 行捕获它时,我们会丢失原始的堆栈跟踪,因为我们现在创建了一个新的 TypeError 类型的错误,只保留原始的错误消息(有时我们甚至都不保留)。

如果 thisThrows() 函数中有更多的逻辑,在该函数的某个地方抛出了一个错误,我们在记录的堆栈跟踪中看不到问题的起源,因为我们创建了一个新的错误,它将生成一个全新的堆栈跟踪。如果我们只是重新抛出原始错误,我们就不会遇到这个问题。

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
function thisThrows() {
throw new Error("Thrown from thisThrows()");
}

function myFunctionThatCatches() {
try {
return thisThrows();
} catch (e) {
// Maybe do something else here first.
throw e;
} finally {
console.log('We do cleanup here');
}
}

async function run() {
try {
await myFunctionThatCatches();
} catch (e) {
console.error(e);
}
}

run();

// Outputs:
// We do cleanup here
// Error: Thrown from thisThrows()
// at thisThrows (/repo/error_stacktrace_2.js:2:11) <-- Notice we now point to the origin of the actual error
// at myFunctionThatCatches (/repo/error_stacktrace_2.js:7:16)
// at run (/repo/error_stacktrace_2.js:18:15)
// at Object.<anonymous> (/repo/error_stacktrace_2.js:24:1)

堆栈跟踪现在指向实际错误的起源,即我们脚本的第 2 行。

处理错误时要意识到这个问题是很重要的。有时这可能是可取的,但通常这会掩盖问题的来源,使得调试问题的根源变得困难。如果你为包装错误创建自定义错误,请确保跟踪原始的堆栈跟踪,以免调试变成一场噩梦。

总结

  • 我们可以使用 try...catch 来处理同步代码。
  • 我们可以使用 try...catch (与 async 函数结合使用)和 .catch() 方法来处理异步代码的错误。
  • try 块中返回一个promise时,如果你希望 try...catch 块捕获错误,请确保 await 它。
  • 在包装错误并重新抛出时,请注意,您会丢失带有错误来源的堆栈跟踪。

Using a Specific SSH Private Key When Using Git Command

一、背景

当不同的git库需要使用不同的private key的时候,可在运行git命令的时候指定私钥 private key。

二、两种方式

使用SSH配置文件

我们可以通过SSH配置文件来指定在git clone过程中使用特定的私钥。

具体来说,我们可以在~/.ssh/config文件中为不同的私钥创建两个单独的主机。然后,在git clone期间,根据我们想要使用的密钥,我们可以在SSH连接字符串中指定不同的主机。

如:

1
2
3
4
5
6
7
8
cat ~/.ssh/config
Host github-work
HostName github.com
IdentityFile ~/.ssh/id_rsa_work

Host github-personal
HostName github.com
IdentityFile ~/.ssh/id_rsa_personal

现在,可以通过id_rsa_work私钥访问的存储库上运行git clone命令,我们可以使用github-work作为其主机来指定SSH连接字符串。

如:

1
2
git clone git@github-work:corporateA/webapp.git
git clone git@github-personal:bob/blog.git

使用core.sshCommand

Git仓库提供了一个可配置的选项core.sshCommand。当运行任何需要SSH隧道的命令时,此配置将覆盖默认的SSH命令。

配置 core.sshCommand

1
git clone -c "core.sshCommand=ssh -i ~/.ssh/id_rsa_work" git@github.com:corporateA/webapp.git

以上例子中,使用-c选项在运行时覆盖core.sshCommand。具体来说,我们使用-i选项将SSH命令更改为指向我们想要使用的私钥。

在存储库级别上持久化core.sshCommand

我们可以使用git config命令将配置覆盖持久化到存储库级别,而不是每次重复配置覆盖

1
git config core.sshCommand "ssh -i ~/.ssh/id_rsa_work"

该命令将core.sshCommand配置持久化到仓库中。这意味着Git将在后续的调用中使用该SSH命令。

可以使用以下命令验证是否配置成功

1
2
$ git config --get core.sshCommand
ssh -i ~/.ssh/id_rsa_work

How To Set Up a Private Docker Registry

Docker Registry是一个管理Docker容器镜像存储和传递的应用程序。注册表集中了容器镜像,减少了开发人员的构建时间。

Docker Hub是一个免费的公共注册表,可以托管您的自定义Docker镜像,但有些情况下您可能不希望您的镜像公开可用。镜像通常包含运行应用程序所需的所有代码,因此在使用专有软件时,使用私有注册表可能更可取。

1. 安装和配置Docker Registry

新建docker-registry目录,存储images

1
2
3
4
5
mkdir ~/docker-registry
cd ~/docker-registry
mkdir data
# 创建 docker-compose文件
vim docker-compose.yml

~/docker-registry/docker-compose.yml

1
2
3
4
5
6
7
8
9
10
11
version: '3'

services:
registry:
image: registry:latest
ports:
- "5000:5000"
environment:
REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /data
volumes:
- ./data:/data

将第一个服务命名为 registry ,并将其图像设置为 registry ,使用最新版本。然后,在 ports 下,将主机上的端口 5000 映射到容器的端口 5000 ,这将允许您向服务器的端口 5000 发送请求,并将请求转发到注册表。

environment 部分,将 REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY 变量设置为 /data ,指定它应该存储数据路径。然后,在 volumes 部分,您将主机文件系统上的 /data 目录映射到容器中的 /data ,它充当一个透传。实际上,数据将存储在主机上。

1
2
# 启动
docker-compose up

在这一步中,已经创建了一个Docker Compose配置,该配置启动了一个在端口 5000 上监听的Docker Registry。在接下来的步骤中,您将在您的域名上公开它并设置身份验证。

2. 设置NGINX转发

这里使用Ubuntu22.04设置,不同的发行版本配置路径会有不同

1
sudo vim /etc/nginx/sites-available/your_domain.conf

Nginx 配置

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
server {
server_name xxxxx.com;

location / {
# Do not allow connections from docker 1.5 and earlier
# docker pre-1.6.0 did not properly set the user agent on ping, catch "Go *" user agents
if ($http_user_agent ~ "^(docker\/1\.(3|4|5(?!\.[0-9]-dev))|Go ).*$" ) {
return 404;
}

proxy_pass http://localhost:5000;
proxy_set_header Host $http_host; # required for docker client's sake
proxy_set_header X-Real-IP $remote_addr; # pass on real client's IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 900;
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /var/www/html;
}

# listen [::]:443 ssl ipv6only=on; # managed by Certbot
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/nginx/cert/xxxx.cert.pem;
ssl_certificate_key /etc/nginx/cert/xxxxxx.key.pem;
}

注:这里配置了HTTPS的证书,需要自己进行签名

重启NGINX

1
sudo systemctl restart nginx

访问 https://your_domain/v2 并返回了一个 {} 的响应。代码 200 表示容器成功处理了该请求,说明NGINX转发已经配置成功

3.设置身份验证(可选)

若暴露在公网中,建议增加身份验证

Nginx允许您为其管理的站点设置HTTP身份验证,您可以使用它来限制对Docker Registry的访问。为了实现这一点,您将创建一个带有 htpasswd 的身份验证文件,并向其中添加将被接受的用户名和密码组合。这个过程将启用对您的注册表的身份验证。

1
2
3
4
5
6
7
8
9
10
# 安装apache2-utils
sudo apt install apache2-utils -y
# 在 ~/docker-registry/auth 下存储带有凭据的身份验证文件。
mkdir ~/docker-registry/auth
cd ~/docker-registry/auth
# 创建第一个用户,将 username 替换为您想要使用的用户名。使用 -B 标志指示使用 bcrypt 算法,这是Docker所要求的。
htpasswd -Bc registry.password username

## 注意:要添加更多用户,请重新运行上一个命令,不包括 -c, -c 创建一个新文件,因此删除它将更新现有文件。
htpasswd -B registry.password username

编辑 docker-compose 文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
version: '3'

services:
registry:
image: registry:latest
ports:
- "5000:5000"
environment:
REGISTRY_AUTH: htpasswd
REGISTRY_AUTH_HTPASSWD_REALM: Registry
REGISTRY_AUTH_HTPASSWD_PATH: /auth/registry.password
REGISTRY_STORAGE_FILESYSTEM_ROOTDIRECTORY: /data
volumes:
- ./auth:/auth
- ./data:/data

指定使用HTTP身份验证并提供创建的文件路径的环境变量。对于 REGISTRY_AUTH ,您指定了 htpasswd 作为其值,这是您正在使用的身份验证方案,并将 REGISTRY_AUTH_HTPASSWD_PATH 设置为身份验证文件的路径。 REGISTRY_AUTH_HTPASSWD_REALM 表示 htpasswd 领域的名称。

然后重新运行

1
docker-compose up

4. 设置自动启动

通过指示Docker Compose始终保持运行状态,确保注册表容器在每次系统启动或崩溃后都会启动。

1
vim docker-compose.yml
1
2
3
4
...
registry:
restart: always
...

restart 设置为 always 可以确保容器在重新启动后仍然存在。完成后,请保存并关闭文件。

现在可以通过传入 -d 来将您的注册表作为后台进程启动。

1
docker-compose up -d

5. 设置NGINX上传文件的大小

在将image推送到register之前,需要确保register能够处理大文件上传。Nginx中文件上传的默认大小限制是 1m ,这对于Docker image来说远远不够。为了提高它,修改位于 /etc/nginx/nginx.conf 的主Nginx配置文件。

1
sudo vim /etc/nginx/nginx.conf

增加

1
2
3
4
5
6
...
http {
client_max_body_size 16384m;
...
}
...
1
2
# 重新启动
sudo systemctl restart nginx

在这一步中,您更新了Nginx允许的文件大小。现在您可以将大型image上传到Docker Registry,而不会被Nginx阻止传输或出现错误。

6. 发布image到Docker register

我们将使用Docker Hub中的Ubuntu image进行测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
docker run -t -i ubuntu /bin/bash
# -i 和 -t 标志为您提供对容器的交互式shell访问。


# 一旦进入,通过运行以下命令创建一个名为 SUCCESS 的文件:
touch /SUCCESS
# 退出容器的Shell
exit


# 从刚刚自定义的容器中创建一个新图像:
docker commit $(docker ps -lq) test-image

# 登录到你的私有Docker register(第三步设置的用户名密码,如果没有设置,则可忽略这步)
docker login https://your_domain
# 登录成功会输出:Login Succeeded

# 重命名Image
docker tag test-image your_domain/test-image

# 推送到image
docker push your_domain/test-image

7. 从register中拉取image

现在你已经将图像推送到了你的私有仓库,你将尝试从中拉取。

1
2
3
4
5
# 登录
docker login https://your_domain

# pull
docker pull your_domain/test-image

总结

在教程中,设置自己的私有Docker注册表并将Docker镜像发布到其中。如介绍中所提到的,您还可以使用TravisCI或类似的CI工具来自动将镜像推送到私有注册表。

通过在工作流程中利用Docker容器,您可以确保包含代码的镜像在任何机器上(无论是在生产环境还是开发环境中)都会产生相同的行为。有关编写Docker文件的更多信息,您可以访问官方文档中的最佳实践部分。

如何在JavaScript中延迟1S

在JS中,需要进行延迟的话,通常的做法是使用**setTimeout,更好的方式是使用Promise**

有很多方法可以让JavaScript等待1秒。有些比其他更好,有些只能在特定情况下使用。

1. 使用setTimeout

如:

1
2
3
4
5
6
console.log("Executed now");

// 1 second delay
setTimeout(function(){
console.log("Executed after 1 second");
}, 1000);

需要特别注意的是,setTimeout是在异步运行的,延迟并不会阻止正常程序的流程,如

1
2
3
4
5
6
7
8
9
console.log("Executed now");

// 1 second delay
setTimeout(function(){
console.log("Executed after 1 second");
}, 1000);

// Notice this!
console.log("Executed before the delay, but after the 1st console.log");

输出:

1
2
3
Executed now
Executed before the delay, but after the 1st console.log
Executed after 1 second

如果你想在同一个延迟函数中不断添加延迟,我们很容易遇到所谓的回调地狱。

2. 使用Promise

如果我们把setTimeout函数和promises函数结合起来,我们就可以创建一个更易读的代码,并把整个代码放在同一个(async)函数中。

与我们之前解释过的setTimeout方法相比,它的主要优点是我们可以重用延迟函数。使我们的代码更加干净和简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function delay(milliseconds){
return new Promise(resolve => {
setTimeout(resolve, milliseconds);
});
}

async function init(){
console.log("Executed now");

await delay(1000);

console.log("Executed after 1 second wait");

await delay(1000);

console.log("Executed after 2 seconds wait");
}

init();

console.log("Executed after the 1st log and before the 2 delays");

输出

1
2
3
4
> Executed now
> Executed after the 1st log and before the 2 delays
> Executed after 1 second wait
> Executed after 2 seconds wait

3. 使用等待循环(loop)

这是唯一完全中断用户流程并强制 JavaScript 进行同步暂停的方法。

这并不是完美的解决方案,我认为这可能是不可取的。JavaScript 是一种异步语言,我们应该利用它,而不是使用巧妙的解决方案使我们的代码变成同步的。

1
2
3
4
5
6
7
8
9
10
11
var wait = (ms) => {
const start = Date.now();
let now = start;
while (now - start < ms) {
now = Date.now();
}
}

console.log("Executed now");
wait(1000);
console.log("Executed after 1 second");

我们基本上使用 Date 函数检查当前时间,并创建一个 do ... while 循环,只有在我们开始循环后超过1000毫秒才会退出。

最大的问题就是?我们基本上是通过运行一个“无意义”的循环并比较日期来让我们的计算机/浏览器忙碌,只是为了延迟。

如果我们等待的时间不是1秒,那么我们的浏览器很可能会崩溃,或者我们的页面停止像平常那样响应。即使我们使用1秒的数值,事情也可能不会总是按我们的期望工作。DOM 渲染发生在 JavaScript 函数栈已清空并且浏览器可以接受新事件之后。

并且为了证明这一点,请看以下示例,在这个示例中, console.log 在1秒后被触发(正如我们所期望的那样),但是两个DOM写入操作同时发生:

上面代码输出

1
2
> Executed now
> Executed after 1 second

这不是一个好的解决方案,如果可能的话,尽量避免采用这个解决方案,而是选择前两种方案。

Add new disk on LVM for Ubuntu 22.04 LTS

这些说明将帮助您在Ubuntu 22.04上向现有的逻辑卷管理(LVM)中添加额外的磁盘。LVM可以帮助您轻松地扩展存储空间跨多个物理磁盘设备。

用于向当前磁盘扩展空间

  1. 找出要分配给LVM的磁盘
1
2
3
4
sudo pvs
PV VG Fmt Attr PSize PFree
/dev/vda3 ubuntu-vg lvm2 a-- <8.25g 0
/dev/vdb ubuntu-vg lvm2 a-- <50.00g 0

查看详细信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ sudo pvdisplay
--- Physical volume ---
PV Name /dev/vda3
VG Name ubuntu-vg
PV Size <8.25 GiB / not usable 0
Allocatable yes (but full)
PE Size 4.00 MiB
Total PE 2111
Free PE 0
Allocated PE 2111
PV UUID 96Cnvs-1rYe-xWk8-lB2V-cLSd-ALqk-4ujiPc

--- Physical volume ---
PV Name /dev/vdb
VG Name ubuntu-vg
PV Size 50.00 GiB / not usable 4.00 MiB
Allocatable yes (but full)
PE Size 4.00 MiB
Total PE 12799
Free PE 0
Allocated PE 12799
PV UUID Hle3gv-3FNW-jkoe-dRFf-CwWd-pdJx-MWy3Ad
  1. 获取LVM的路径
1
2
3
$ sudo lvdisplay
--- Logical volume ---
LV Path /dev/ubuntu-vg/ubuntu-lv

省略了其他信息

  1. 找到要添加的新磁盘
1
2
3
4
5
6
7
8
9
10
$ sudo fdisk -l | grep '^Disk /dev/'
Disk /dev/loop0: 63.24 MiB, 66314240 bytes, 129520 sectors
Disk /dev/loop1: 63.23 MiB, 66301952 bytes, 129496 sectors
Disk /dev/loop2: 79.95 MiB, 83832832 bytes, 163736 sectors
Disk /dev/loop3: 102.98 MiB, 107986944 bytes, 210912 sectors
Disk /dev/loop4: 49.62 MiB, 52031488 bytes, 101624 sectors
Disk /dev/vda: 10 GiB, 10737418240 bytes, 20971520 sectors
Disk /dev/vdb: 50 GiB, 53687091200 bytes, 104857600 sectors
Disk /dev/vdc: 500 GiB, 536870912000 bytes, 1048576000 sectors
Disk /dev/mapper/ubuntu--vg-ubuntu--lv: 558.24 GiB, 599403790336 bytes, 1170710528 sectors

在本例中,它将是/dev/vdc,因为我向这个实例添加了一个500GB的磁盘驱动器。

  1. 在新的磁盘驱动器上创建物理卷
1
2
$ sudo pvcreate /dev/vdc
Physical volume "/dev/vdc" successfully created.
  1. 扩展现有卷组以包含这个新的磁盘驱动器
1
2
$ sudo vgextend ubuntu-vg /dev/vdc
Volume group "ubuntu-vg" successfully extended
  1. 扩展LV大小以包含100%的新磁盘
1
2
3
$ sudo lvm lvextend -l +100%FREE /dev/ubuntu-vg/ubuntu-lv
Size of logical volume ubuntu-vg/ubuntu-lv changed from 58.24 GiB (14910 extents) to <558.24 GiB (142909 extents).
Logical volume ubuntu-vg/ubuntu-lv successfully resized.
  1. 现在需要调整文件系统的大小以匹配新的大小
1
2
3
4
5
$ sudo resize2fs -p /dev/ubuntu-vg/ubuntu-lv
resize2fs 1.46.5 (30-Dec-2021)
Filesystem at /dev/ubuntu-vg/ubuntu-lv is mounted on /; on-line resizing required
old_desc_blocks = 8, new_desc_blocks = 70
The filesystem on /dev/ubuntu-vg/ubuntu-lv is now 146338816 (4k) blocks long.
  1. 现在可以用df -kh来验证
1
2
3
4
5
6
7
8
9
$ df -kh
Filesystem Size Used Avail Use% Mounted on
tmpfs 9.9G 1.6M 9.9G 1% /run
/dev/mapper/ubuntu--vg-ubuntu--lv 550G 8.9G 519G 2% /
tmpfs 50G 0 50G 0% /dev/shm
tmpfs 5.0M 0 5.0M 0% /run/lock
tmpfs 50G 0 50G 0% /run/qemu
/dev/vda2 1.7G 247M 1.4G 16% /boot
tmpfs 9.9G 4.0K 9.9G 1% /run/user/1000

参考:

使用Fastapi实现 server-sent events (SSE)

服务器推送事件(SSE)是一种在不重新加载页面的情况下向浏览器发送数据的方式。这使得您可以使用流式数据并构建可用于各种情境的实时应用程序。

在本教程中,我们将使用FastAPI创建一个简单的SSE服务器,该服务器将每秒发送一条消息。

安装相关包

1
2
3
pip install "fastapi[all]"
pip install sse-starlette
pip install asyncio

创建一个简单的Fastapi项目

main.py

1
2
3
4
5
6
7
8
9
10
import asyncio
import uvicorn
from fastapi import FastAPI, Request

app = FastAPI()


@app.get("/")
async def root():
return {"message": "Hello World"}

使用uvicorn 运行

1
uvicorn main:app --reload

这将在8000端口上运行服务器。 –reload标志将自动重新加载服务器当你对代码进行更改时,这样你就不必每次更改时都重新启动服务器。

增加SSE逻辑

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
STREAM_DELAY = 1  # second
RETRY_TIMEOUT = 15000 # milisecond

@app.get('/stream')
async def message_stream(request: Request):
def new_messages():
# Add logic here to check for new messages
yield 'Hello World'
async def event_generator():
while True:
# If client closes connection, stop sending events
if await request.is_disconnected():
break

# Checks for new messages and return them to client if any
if new_messages():
yield {
"event": "new_message",
"id": "message_id",
"retry": RETRY_TIMEOUT,
"data": "message_content"
}

await asyncio.sleep(STREAM_DELAY)

return EventSourceResponse(event_generator())

依赖

  • NGINX
  • 认证文件创建工具,这里演示的是使用apache2-utils

创建认证文件

  1. 安装apache2-utils
1
sudo apt install apache2-utils
  1. 创建认证文件,-c是创建新的文件
1
2
3
4
sudo htpasswd -c /etc/apache2/.htpasswd user1
# 按提示输入user1的密码
# 也可以直接在命令中输入密码,加上-b参数
sudo htpasswd -cb /etc/apache2/.htpasswd user1 password
  1. 添加额外的用户
1
sudo htpasswd /etc/apache2/.htpasswd user2
  1. 查看/etc/apache2/.htpasswd文件,示例内容如下
1
2
3
4
$ cat /etc/apache2/.htpasswd
user1:$apr1$/woC1jnP$KAh0SsVn5qeSMjTtn0E9Q0
user2:$apr1$QdR8fNLT$vbCEEzDj7LyqCMyNpSoBh/
user3:$apr1$Mr5A0e.U$0j39Hp5FfxRkneklXaMrr/

配置NGINX

  1. 在要加认证的路径中,增加 auth_basic配置
1
2
3
4
location /api {
auth_basic "Administrator’s Area";
#...
}
  1. 配置认证文件 auth_basic_user_file
1
2
3
4
location /api {
auth_basic "Administrator’s Area";
auth_basic_user_file /etc/apache2/.htpasswd;
}

同样,也可以配置在整个server下面,如果某个路径不需要认证,增加auth_basic:off即可

1
2
3
4
5
6
7
8
9
server {
...
auth_basic "Administrator’s Area";
auth_basic_user_file conf/htpasswd;

location /public/ {
auth_basic off;
}
}

Basic Authentication与IP相结合

设想以下场景

  • 既要认证,又要要求IP白名单才可以访问
  • 认证或者IP白名单可以访问
  1. 使用 allowdeny指令
1
2
3
4
5
6
7
location /api {
#...
deny 192.168.1.2;
allow 192.168.1.1/24;
allow 127.0.0.1;
deny all;
}

拒绝来自192.168.1.2的访问,允许192.168.1.1/24网段内的访问。 deny, allow按顺序匹配。

  1. 结合satisfy 指令,如果设置为all,则需要满足所有条件才可以访问,如果设置为any,则ip认证和basic auth认证满足其中一个即可,如
1
2
3
4
5
6
7
8
9
10
11
12
location /api {
#...
satisfy all;

deny 192.168.1.2;
allow 192.168.1.1/24;
allow 127.0.0.1;
deny all;

auth_basic "Administrator’s Area";
auth_basic_user_file conf/htpasswd;
}

CURL的 –url-query的用法

背景

2022年12月21日发布的curl 7.87.0版本中,新增了–url-query参数,详见commit

在此之前,如果要使用post方法发送数据,可以使用-d,将数据包装成body发送出去。

1
curl -d name=mrsmith -d color=blue https://example.com

如果转变为GET方式,可以使用-G/–get参数,会自动将参数转变为get的query的形式,如

1
curl -G -d name=mrsmith -d color=blue https://example.com

将URL转为:

1
https://example.com/?name=mrsmith&color=blue

但是,如果使用POST的方式,既要携带body,又要使用query参数呢? 就需要手动构建带有query参数的url,使用-d携带body。

–url-query就是来解决这个问题的。

简单的例子

1
curl -d name=mrsmith -d color=blue --url-query name=mrsmith --url-query color=blue https://example.com

基本语法

–url-query [data], [data]需要符合以下用法

说明
content 会对数据进行url-encode编码发送,但是需要注意的是,其中不能包含=或@符号,这两个符有特殊用途
=content 会对数据进行url-encode编码后发送
name=content 会对数据进行url-encode编码后发送
@filename 会从指定的文件总加载数据,对数据进行url-encode编码,并通过post进行发送
name@filename 将会总给定的文件中加载数据,对数据进行url-encode编码,并通过post进行发送。最终的数据结构是 name=urlencoded-file-content。
+content 不进行url-encode编码,以原始数据传送

对于多个–url-query,curl会自动添加&符号。

参考链接

将字典转变为字符串 dict to string

Python3.x

1
2
3
4
5
6
# urllib.parse.urlencode(query, doseq=False, [...])
# Convert a mapping object or a sequence of two-element tuples, which may contain str or bytes objects, to a percent-encoded ASCII text string.
# Example
from urllib.parse import urlencode
urlencode({'pram1': 'foo', 'param2': 'bar'})
# pram1=foo&param2=bar

Python2.x

1
2
3
4
from urllib import urlencode

data = {'name': 'Desmond Lua', 'age': 40}
query_string = urlencode(data)

Python2 和Python3的兼容写法

1
2
3
4
5
6
try:
#python2
from urllib import urlencode
except ImportError:
#python3
from urllib.parse import urlencode

将字符串转换为字典 string to dict

Python3

方法1 使用urllib.parse.parse_qs()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# import module
import urllib.parse

# initializing string
test_str = 'gfg=4&is=5&best=yes'

# printing original string
print("The original string is : " + str(test_str))

# parse_qs gets the Dictionary and value list
res = urllib.parse.parse_qs(test_str)

# printing result
print("The parsed URL Params : " + str(res))

# The parsed URL Params : {'gfg': ['4'], 'is': ['5'], 'best': ['yes']}

方法2 使用正则表达式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import re

# initializing string
test_str = 'gfg=4&is=5&best=yes'

# printing original string
print("The original string is : " + str(test_str))

# getting all params
params = re.findall(r'([^=&]+)=([^=&]+)', test_str)

# assigning keys with values
res = dict()
for key, val in params:

res.setdefault(key, []).append(val)

# printing result
print("The parsed URL Params : " + str(res))

方法3 使用Split()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#python program to convert
#URL parameters to dictionary items
# initializing string
test_str = 'gfg=4&is=5&best=yes'

# printing original string
print("The original string is : " + str(test_str))

# getting all params
res = dict()
x=test_str.split("&")
for i in x:
a,b=i.split("=")
# assigning keys with values
res[a]=[b]
# printing result
print("The parsed URL Params : " + str(res))

#The original string is : gfg=4&is=5&best=yes
# The parsed URL Params : {'gfg': ['4'], 'is': ['5'], 'best': ['yes']}

背景

一个简单的API服务,由于经常改动,每次改动后还需要手动登录服务器,pull下代码,然后重启,作为多一步都嫌麻烦的人来说,绝对不能接受。

功能设想

由于代码仓库是部署在github上的,想到了github action,由于之前没有了解过github action,不知道能否实现,于是开始了学习github action的过程,发现完全没问题,亦有种相见恨晚的感觉。

主要实现的点:

  • 当代码push到master分支后,自动pull下来代码;
  • 然后重启服务;

就是这么简单的几个步骤。

实现细节

考虑到服务不复杂且较简单,依赖不多,本着怎么简单怎么来,直接使用ssh登录到服务器执行命令即可。

前期准备

准备ssh免密登录的private key,我这里直接用我自己机器的key,建议重新生成一个

1
2
3
4
# 生成key
ssh-keygen -t rsa -C "github actions"
# 复制到服务器
ssh-copy-id -i [公钥文件] user@host

在github的仓库中设置思考,处于安全性的考虑,使用GitHub的Secrets。 在项目的Settings->Secrets->Actions增加私钥,我这边将服务器的Host,Username都配置进去了,方便修改。

Actions文件

存储在项目目录的.github/workflows/deploy.yml,文件名可以随便起,路径必须对。

actions文件示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
name: deploy
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: executing remote ssh commands to develop
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SECRET }}
script: cd [path] && git pull && sudo supervisorctl restart param

我这里用的是supervisor启动的服务,如果用其他方式启动的,可替换自己的重启命令。

另外,使用到了ssh-action, 其中:

  • host:为自己主机的地址,这里设置在了项目的secrets中
  • username: 为主机ssh的用户名,同样写在了项目的secrets中
  • key:为登录主机的私钥

其他参数可参考ssh-action的项目主页。

此时,可直接push到master分支来测试了。

可完善的点

  • 对于项目依赖,没有自动安装,可增加pip3 install -r requirements自动安装相关依赖,需要做好错误处理;
  • 部署成功或失败,可增加监控或者通知;

reference