毕设踩坑整理

本文最后更新于:2022年3月27日 晚上

说是踩坑,实际上只是整理我在毕设中遇到的一些问题和解决方案,不过不知道以后还有没有再写 flask 的机会……

此外写毕设的一个感触就是,往往构思的时间要比实际写代码的时间要多得多。同时,自己可能由于过分想要避免重复造轮子,而且总是想找到解决某个问题的 best practice,常常过分纠结于工具的选择,而浪费了大量的时间,实际上一些比较小的问题,自己动手去做个适配,或者撸个轮子能够很好 work 就可以了。

CSS tricks

用得最多的应该是margin-left: auto,自动居右

前后端

python 删除目录

可以使用 shutil.rmtree(path),不过缺点是有时你只是想删除目录中的内容,而非将目录本身也删去。虽然可以选择重新创建目录,但是如果目录是在挂载在 Docker 中可能会出现一些问题,所以我的做法是

1
2
3
4
5
6
def clear_dir(folder):
for root, dirs, files in os.walk(folder, topdown=False):
for name in files:
os.remove(os.path.join(root, name))
for name in dirs:
os.rmdir(os.path.join(root, name))

topdown=False是因为rmdir()需要目录为空,可以参考相关文档[1]

前后端下载文件

前端下载文件

1
2
3
4
5
6
7
8
9
10
11
12
axios({
url: 'http://localhost:5000/static/example.pdf',
method: 'GET',
responseType: 'blob', // important
}).then((response) => {
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'file.pdf');
document.body.appendChild(link);
link.click();
});

我在 SO 搜索时看到很多人推荐 file-saver,其对浏览器的兼容性更好,但是据作者所言,其更加适用于 client-side 的文件(即直接在浏览器端生成的文件),如果想要下载后的文件是以后端给出的 filename 命名的话,有一些麻烦。

如果希望下载后的文件是以后端给出的 filename 命名的话,可以在 Response 中指定 content-disposition

1
2
3
4
return Response(data, headers={
'content-type': 'application/zip',
'content-disposition': 'attachment; filename=%s;' % zip_name,
})

在这里我一开始遇到了返回的 Response 中读不到content-disposition的问题,这里是由于我的 flask 使用了 flask_cors 来进行跨域设置,而该 header 默认未被暴露,需要cors.init_app(app, expose_headers=["Content-Disposition"])进行设置

而在前端则使用正则来获取文件名

1
2
3
4
5
6
7
let headers = response.headers
let filename = ''
let filenameRegex = /filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/
let matches = filenameRegex.exec(headers['content-disposition']);
if (matches != null && matches[1]) {
filename = matches[1].replace(/['"]/g, '');
}

流式下载

如果提供的文件比较大,可能很难全部 load 到内存中(像data = f.readlines()那样),flask 提供了 Streaming Content 的支持[2],这边又涉及到如何 line by line 的去读一个大文件,这边参考该回答[3]

1
2
3
4
5
6
7
8
def generate():
with open(tmp_zip_path, 'rb') as f:
for line in f:
yield line
return current_app.response_class(generate(), headers={
'content-type': 'application/zip',
'content-disposition': 'attachment; filename=%s;' % zip_name,
})

也可以通过 chunkSize 进行更细粒度的控制,而前端的代码其实没有什么要改的。

一般来说,我们时常遇到的下载方式分为两种:

  1. 直接访问服务器的文件地址,自动下载文件
  2. 返回 blob 文件流,再对文件流进行处理和下载

在这里使用了后者,这种方式的缺陷是用户无法感知文件流的传输状态,会造成一些困扰(无法确定当前下载操作是否生效),尤其是在下载的文件比较大,不能很快下载完的情况下。

这时可以设置 axios 的 onDownloadProgress 回调来进行处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
axios({
url: 'http://localhost:5000/static/example.pdf',
method: 'GET',
responseType: 'blob', // important
onDownloadProgress: progressEvent => {
let percentCompleted = Math.floor(progressEvent.loaded / progressEvent.total * 100)
// use percentCompleted to show progress
...
}
}).then((response) => {
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', 'file.pdf');
document.body.appendChild(link);
link.click();
});

这边使用到了 progressEvent.total,在 flask 返回的 Response 需要添加content-length该 header, 否则前端获取到的 total 值会为 0,在 python 中可以使用os.path.getsize(file_path)得到文件大小

file-saver 的作者还有构建了个 StreamSaver,在该篇回答中有所提及[4]

文件下载后删除

前面提到我使用了 blob 文件流,是因为下载文件是接受到用户请求后,在服务器上对一些文件打包成 zip 生成的,在下载结束后需要继续删除,我参考了这篇回答[5],由于我正好使用了 Streaming Content, 所以在generate()中在读取后进行删除即可

1
2
3
4
5
6
7
8
9
10
11
def generate_and_remove_file():
with open(tmp_zip_path, 'rb') as f:
for line in f:
yield line
os.remove(tmp_zip_path)
os.remove(tmp_json_path)
return current_app.response_class(generate_and_remove_file(), headers={
'content-type': 'application/zip',
'content-disposition': 'attachment; filename=%s;' % zip_name,
'content-length': file_size
})

当然我也认为,最后所提到的,使用 APScheduler 或者 Celery 这样的异步定时任务清理更加 elegant 和 robust

一些补充

@davidism 在他的回答[6]中提到了静态文件可以使用send_from_directory()以及支持 X-SendFile 的如 Nginx 这样的服务器,不过尽管项目发送给用户的 zip 文件会实际生成在临时文件夹中,但是总归要删除的,而且大概率不会被多次请求,不知道 Nginx 对它进行缓存是否会成为一种浪费

Axios Network Error

前面提到了引入 progress 的一个原因就是不知道 blob 文件流下载操作是否生效,我在项目编写中遇到过这样的一个问题, flask 后端显示的状态码是 200,但是浏览器 console 中看到的是 ERR_INVALID_RESPONSE或是ERR_CONNECTION_RESET, axios 的 issues 有个讨论帖,可惜其中的方法目前对于我来说并不 work, 所以我还是通过加个进度提醒,期望用户看到操作无反应后,多尝试几次。

容器化

在开发时,使用flask run会自动加载项目目录下的.env.flaskenv;在考虑部署时,flask 自带的服务器不适用于生产环境,往往需要使用 gunicron 或者 uwsgi 这样的 WSGI Server.在非容器化部署时,应用并不会自动加载环境文件(因为此时使用的不再是flask run),则需要在项目启动的文件(常规命名是app.pywsgi.py)中,给create_app()传入production作为参数,并利用 dotenv 的load_dotenv()手动加载环境文件。

但是在容器化部署时,可以将环境变量文件直接传递给容器进行加载,相对来说更为方便一点。

参考


毕设踩坑整理
https://flaglord.com/2022/03/27/毕设踩坑整理/
作者
flaglord
发布于
2022年3月27日
许可协议