/ Django

迷之操作|Django版静态Note生成器

Powered by Django

正如这句话而言,目前这个网站是用Django来搭建的,至于当初为什么要选择Django,可能是因为它作为最流行的Web框架之一,开发迅速,以及自带的admin管理,可以迅速成型一个小型站点,YYDS。

Why change

最初这个网站是部署在HK的一个VPS上的,配置也是最低配置,解析是套了一层CF,所以造成国内访问非常慢。不过也可以忍受,就没在这上面折腾。按理说这种网站是用来写的,但我好像不务正业的折腾起来了页面,每换一种风格,用不了多久就觉得看着不舒服,于是又重新改了一套页面,于是又重复于此。

前段时间正好赶上各大云厂商双11做活动,于是在结束的最后一天在良心云上薅了一台国内的VPS,配置要比之前的好一点,而且在国内速度跟之前比也是起飞一般的速度。

在迁移项目的时候突然想到,现在的数据都是在当前主机上的,万一哪一天服务器忘记续费的话,等到期的时候数据岂不是就全丢了,要是把数据全上传到Git,这样不就不用担心了,而且切换服务器的时候,直接把项目拉下来直接就能跑,就像Page服务一样,那岂不是妙哉。

Ready to go

切换数据库从MySQL到SqlLite

因为SqlLite数据库是一个本地文件数据库,对于这种小型站点来说,十分便携方便,而且还可以直接上传到Git,所以就把数据全导入到这个数据库里边了。

全站静态化

首先统计网站的页面都有哪些,每个页面的路由都是什么,通过这些可以组织静态网页存放的目录结构,而且这些在之后的Nginx配置中也十分重要。

比如我的文章路由为https://itsso.cool/blog/django-staticize.html,那么文章 django-staticize.html就应该放在静态文件根目录的 blog目录下。

由于平时写笔记喜欢在Typora上写完之后,在把文章源码复制到在线的编辑器内,再保存上传发布,同时笔记文件也会同时上传到Github以做备份。但是这样就会存在一个问题,假入想要修改其中某一篇内容的话,重复上面的步骤,实在繁琐,要是把笔记文件连同静态文件在项目部署的时候一起打包发布就好了。

所以就开始改造模型,修改文章内容字段为FileField,将笔记文件的相对路径保存到本地数据库内,每次写完就直接把文件上传。

在构建静态文件的时候,数据来源从本地保存的文件来读取,这样一来既可以将笔记备份,同时在修改内容的时候也可以极大的缩减上传步骤。

给Typora配置专属图片上传服务器

在写笔记的时候难免会有一些图片,在上传这些图片的时候就会比较麻烦,因为Typora里边文件存放的只是一个当前主机的绝对路径,直接将笔记发布到线上的时候,图片会因为路径的问题加载不出来。此时就需要将图片保存到一个公网可以访问的一个图床内。

好多人的做法是将自己的图片上传到微博、知乎、csdn等一些知名网站上,然后再将这些图片链接插入到自己的笔记内。这样做虽然是很方便快速,但是也会存在一些问题,就比如微博在某一天,突然给自己的链接加上了防盗链,导致了所有的图片在自己以外的网站不能继续使用。

另外的像GitHub/Gitee Page服务,也可以将自己的图片上传到他们的服务器,但是据说会存在不稳定的情况,想了想还是算了。

自己的东西掌握在自己手里才踏实,于是就自己搞了一个专属于自己的图床服务,这次倒是没有选择Django,而且选择了更合适轻量化的Flask。

#!/usr/bin/python
# -*- coding: UTF-8 -*-
import os
import random
import string
from datetime import datetime

from flask import Flask, request
from markupsafe import escape
from werkzeug.utils import secure_filename

from dotenv import load_dotenv

env_path = '.env'
load_dotenv(dotenv_path=env_path)

STATIC_FOLDER = "images"
STATIC_URL = "/img/"

app = Flask(__name__, static_folder=STATIC_FOLDER, static_url_path=STATIC_URL)

app.config['UPLOAD_FOLDER'] = STATIC_FOLDER
basedir = os.path.abspath(os.path.dirname(__file__))
ALLOWED_EXTENSIONS = {'txt', 'png', 'jpg', 'xls', 'JPG', 'PNG', 'xlsx', 'gif', 'GIF'}

HOSTNAME = os.getenv('HOSTNAME')


# 用于判断文件后缀
def allowed_file(filename):
    return '.' in filename and filename.rsplit('.', 1)[1] in ALLOWED_EXTENSIONS


# 上传文件
@app.route('/api/upload', methods=['POST'], strict_slashes=False)
def api_upload():
    access_token = request.form.get("access_token") or " "
    if access_token != os.getenv("ACCESS_TOKEN"):
        return "error", 403

    time_path = datetime.now().strftime("%Y/%m/")
    file_dir = os.path.join(basedir, app.config['UPLOAD_FOLDER'], time_path)
    if not os.path.exists(file_dir):
        os.makedirs(file_dir)
    f = request.files['file']  # 从表单的file字段获取文件,myfile为该表单的name值
    if f and allowed_file(f.filename):  # 判断是否是允许上传的文件类型
        filename = secure_filename(f.filename)
        ext = filename.rsplit('.', 1)[-1]  # 获取文件后缀
        ran_str = ''.join(random.sample(string.ascii_letters + string.digits, 8))
        new_filename = ran_str + '.' + ext  # 修改了上传的文件名
        f.save(os.path.join(file_dir, new_filename))  # 保存文件到upload目录
        return HOSTNAME + STATIC_URL + time_path + escape(new_filename)
    else:
        return "不支持的文件格式!"


if __name__ == '__main__':
    app.run(debug=True, port=8080)

仅需这些代码就可以搭建一个轻量的图片上传服务器,Flask真香~

接下来就是配置Typora,在图片复制进来的时候自动上传到服务器

在Typora→Preferences→Image→Image Uploader选择Custom Command,同时在When Insert选择Upload image

kwhpUo4f

部署到服务器,接下来我们选择使用脚本来上传,那么就需要一个脚本来对接我们的图片服务器

#!/bin/bash

# 各类配置信息
base_url="https://img.itsso.cool/api/upload/"
access_token="xxxxxxxxxx"

# 上传图片
for i in "$@"; do
    curl -POST $base_url -H "Content-Type:multipart/form-data" -F "file=@$i" -F "access_token=$access_token"
    echo ""
done

点击Test Uploader就可以测试一下我们脚本是否可以正常上传

swQeFOjS

现在来看一下效果如何,复制一张图片到Typora。

LAbunMRk

配置WebHook让远程服务器自动拉取更新代码

所谓webhook,就是用户可以自定义一种回调函数,通过这种回调函数来改变web应用的行为,这些回调函数可以是web应用的开发者,也可以是第三方用户,并且与原始的web应用没有关联。

这里采用的是Flask搭建一个webhook服务,并通过该服务触发脚本来完成整个流程操作

首先在GitHub上开通webhook,并添加secret,选择settings→webhooks

使用Flask搭建一个简易的webhook服务,其中要注意对请求来源做验证,GitHub官方已经提供了验证方法

Note: For backward-compatibility, we also include the X-Hub-Signature header that is generated using the SHA-1 hash function. If possible, we recommend that you use the X-Hub-Signature-256 header for improved security. The example below demonstrates using the X-Hub-Signature-256 header.

接下来就是完整的代码:webhook.py

import hmac
from flask import Flask, request, jsonify
import subprocess

app = Flask(__name__)
# github中webhooks的secret
github_secret = 'xxxxxxxx'

def encryption(data):
    key = github_secret.encode('utf-8')
    obj = hmac.new(key, msg=data, digestmod='sha256')
    return obj.hexdigest()

@app.route('/hook', methods=['POST'])
def post_data():
    """
    github加密是将post提交的data和WebHooks的secret通过hmac的sha256加密,放到HTTP headers的
    X-Hub-Signature256参数中
    """
    post_data = request.data
    token = encryption(post_data)
    # 认证签名是否有效
    signature = request.headers.get('X-Hub-Signature-256', '').split('=')[-1]
    if signature != token:
        return "token认证无效", 401
    # 运行shell脚本,更新代码
    subprocess.run(["bash", "auto_deploy.sh"])
    return jsonify({"status": 200})

if __name__ == '__main__':
    app.run(port=9000)

要触发的脚本文件:auto_deploy.sh

cd "$(dirname "$0")"
echo '--------Git fetch------------'
git fetch
echo '--------Git merge------------'
git merge
echo '-----Already up-to-date------'
echo '----- reload nginx-----'
nginx -s reload

接下来将webhook服务部署好,在项目文件夹下push代码的时候,就会触发该hook,在远程服务器自动将更新的代码拉取下来。

到此为止,用了奇奇怪怪的方法实现了自己奇奇怪怪的想法。

其实现在已经有很多很成熟的静态博客生成器,比如Hugo,Jekyll以及Hexo等等,以前搞过一次,后来网站没了,再加上觉得上传方式并不是那么Geek,也有可能是我没找到一些方便的方法,就直接放弃了。

后来重新搭建网站的时候,就打算干脆直接搭建一个动态的网站,至少很多东西要比静态的灵活方便。

至于现在为什么又变成这样了,那可能是

脑子抽风了吧。

Django批量创建时出现bulk_create内存异常

背景

因为需要往项目数据库上传大量数据,数据是以文件的方式存储,所以采用django的bulk_create批量读取并上传,但是在上传过程中发现,上传程序占用的内存一直在上升,甚至到最后直接把内存占满了。

排查问题

刚开始首先怀疑的就是程序代码有问题导致内存没有释放,自己看没发现什么问题,请教同事帮忙看也没有发现什么问题,所以干脆就硬着头皮去试代码。在每次循环之后都加入gc.collect(),尝试主动释放内存,发现问题仍然存在。

于是开始尝试内存排查工具tracemalloc来排查什么地方一直在增加内存

def batch_insert(filepath):
    tracemalloc.start()
    start_snapshot=tracemalloc.take_snapshot()  # 建立快照
    path = Path(filepath)
    for i, p in enumerate(sorted(path.glob('**/*.json')), start=1):
        with p.open(encoding='utf-8') as f:
            data = json.load(f)
    			... # 内容整理
        with transaction.atomic():
            try:
              	# 批量插入数据库
                Regulation.objects.bulk_create(rules)
            except Exception as e:
                print(e)

        gc.collect()	# 主动释放内存,
       	end_snapshot = tracemalloc.take_snapshot()
        top_stats = snapshot.compare_to(start_snapshot, 'lineno')
        for index, stat in enumerate(top_stats[:50], 1):
            frame = stat.traceback[0]
            print("#%s: %s:%s: %.1f KiB"
                  % (index, frame.filename, frame.lineno, stat.size / 1024))

RFK6GbLQ

ewSJd0CP

发现内存主要的增长是在第一行,而且后面也主要和MySQLdb有关,那么就去看看到底是怎么回事,点开源码在发现django/utils/encoding.py:62

def force_str(s, encoding='utf-8', strings_only=False, errors='strict'):
    """
    Similar to smart_str(), except that lazy instances are resolved to
    strings, rather than kept as lazy objects.

    If strings_only is True, don't convert (some) non-string-like objects.
    """
    # Handle the common case first for performance reasons.
    if issubclass(type(s), str):
        return s
    if strings_only and is_protected_type(s):
        return s
    try:
        if isinstance(s, bytes):
            s = str(s, encoding, errors)
        else:
            s = str(s)
    except UnicodeDecodeError as e:
        raise DjangoUnicodeDecodeError(s, *e.args)
    return s

虽然有注解,但还是不明所以,那最起码看看是谁在调用这个方法总也行吧

grep -nr 'force_str('

发现还不少调用,那就找到关键的信息继续往上追溯

上面那么多虽然都有调用,但是与我们实际的使用情况不符,因为使用的mysql,所以发现红线部分挺符合预期,继续查看代码django/db/backends/mysql/operations.py:171

def last_executed_query(self, cursor, sql, params):
    # With MySQLdb, cursor objects have an (undocumented) "_executed"
    # attribute where the exact query sent to the database is saved.
    # See MySQLdb/cursors.py in the source distribution.
    # MySQLdb returns string, PyMySQL bytes.
    return force_str(getattr(cursor, '_executed', None), errors='replace')

继续查找调用改方法的地方

grep -nr 'last_executed_query('

找到源码django./db/backends/utils.py:113

@contextmanager
def debug_sql(self, sql=None, params=None, use_last_executed_query=False, many=False):
    start = time.monotonic()
  try:
    yield
  finally:
    stop = time.monotonic()
    duration = stop - start
    if use_last_executed_query:
      sql = self.db.ops.last_executed_query(self.cursor, sql, params)
      try:
        times = len(params) if many else ''
        except TypeError:
          # params could be an iterator.
          times = '?'
          self.db.queries_log.append({
            'sql': '%s times: %s' % (times, sql) if many else sql,
            'time': '%.3f' % duration,
          })
          logger.debug(
            '(%.3f) %s; args=%s',
            duration,
            sql,
            params,
            extra={'duration': duration, 'sql': sql, 'params': params},
          )

研究一下代码发现,sql = self.db.ops.last_executed_query(self.cursor, sql, params),在批量上传的时候,会将所有要上传的内容变成一条sql语句,到此为止目前还没发现有什么异常。

但是看到下面这一段,Django将生成的sql语句保存起来,那这个对象会清空之前保存的sql吗?

self.db.queries_log.append({
                'sql': '%s times: %s' % (times, sql) if many else sql,
                'time': '%.3f' % duration,
            })
            logger.debug(
                '(%.3f) %s; args=%s',
                duration,
                sql,
                params,
                extra={'duration': duration, 'sql': sql, 'params': params},
            )

经过Debug发现,是不会的,每生成一条sql就添加到self.db.queries_log里,只要程序没有停止,那么这里边的sql就永远不会消失,所以真相大白,原来就是这个家伙导致的内存一直飙升。

继续向上看,寻找是否存在参数可以选择是否保存这些sql,在django/db/backends/utils.py:97发现这段代码

def execute(self, sql, params=None):
    with self.debug_sql(sql, params, use_last_executed_query=True):
            return super().execute(sql, params)

发现确实存在一个参数use_last_executed_query,但是这个参数已经写死在代码里。。。

尝试修改源码将use_last_executed_query设置为False,运行代码发现问题解决。

后来有尝试将sql语句放入self.db.queries_log这段代码注释掉,运行代码仍然可以解决。

至此已经发现问题的真正根源出在什么地方了,但是通过修改源码的方式总归是不合适的,继续尝试有没有Django自带的方案。经过查找发现django/db/__init.py:26

# Register an event to reset saved queries when a Django request is started.
def reset_queries(**kwargs):
    for conn in connections.all():
        conn.queries_log.clear()

于是尝试在代码中引入该方法

from django.db import reset_queries

...
# 插入数据库之前首先清空
reset_queries()
# 批量插入数据库
Regulation.objects.bulk_create(rules)

发现问题完美的得到解决,完美!

后来在官方文档中发现,已经有这个方法的相关文档

dmTAMrGI

文档中还说只有在DEBUG=True时,这些sql语句才会保存起来以便有需要的时候查看sql。

这也解答了我心中的一个疑问,系统运行时产生的sql为什么要保存起来呢,现在看来是我知识浅薄了。

所以最终这个问题的解决方案有两个:

一、将DEBUG设置为False

二、手动清除,引入django.db.reset_queries

就第一种方案而言,只有在生产环境下,DEBUG选项才会为False,所以在自己的电脑上或者测试机上运行,还是第二种方案比较好。