Shellbye.github.io icon indicating copy to clipboard operation
Shellbye.github.io copied to clipboard

Bottle的自动重启机制

Open Shellbye opened this issue 6 years ago • 0 comments

Bottle支持在开发过程中自动监听文件改动,并重启服务。本文通过阅读其源码,来探究它是如何做到这一点的。

执行代码

本文使用0.12的稳定版本,完整源码见这里。 以下代码就可以启动一个WEB服务,并监听文件改动。

# -*- coding:utf-8 -*-
# Created by shellbye on 2018/5/24.

from bottle import run, route

@route('/')
def hello():
    return "Hello World! "


run(host='localhost', port=8050, reloader=True)

修改该文件并重新刷新浏览器,会看到如下执行日志,证明服务重启了

Bottle v0.12.13 server starting up (using WSGIRefServer())...
Listening on http://localhost:8050/
Hit Ctrl-C to quit.

127.0.0.1 - - [24/May/2018 11:57:12] "GET / HTTP/1.1" 200 13
Bottle v0.12.13 server starting up (using WSGIRefServer())...
Listening on http://localhost:8050/
Hit Ctrl-C to quit.

127.0.0.1 - - [24/May/2018 11:57:23] "GET / HTTP/1.1" 200 16

原理解释

官方对于自动重启的文档介绍如下:

How it works: the main process will not start a server, but spawn a new child process using the same command line arguments used to start the main process. All module-level code is executed at least twice! Be careful.

The child process will have os.environ['BOTTLE_CHILD'] set to True and start as a normal non-reloading app server. As soon as any of the loaded modules changes, the child process is terminated and re-spawned by the main process.

意思就是主进程启动时,并没有启动服务本身,而是派生出了一个和主进程本身一摸一样的子进程,并由该子进程完成服务的提供。所有模块级别的代码都会因为这个原因执行两次。子进程中环境变量os.environ['BOTTLE_CHILD']设置为True,任何模块文件的改动都会导致子进程被销毁重建。

源码

下面我们来一下看看源码,主进程判断是否开始自动重启机制,以及如果开始就派生子进程的程序是在服务一开始就要进行的,由下面这段代码完成以上工作:

if reloader and not os.environ.get('BOTTLE_CHILD'):  #主进程才会进入到以下内容,子进程则直接跳过了
    try:
        lockfile = None
        fd, lockfile = tempfile.mkstemp(prefix='bottle.', suffix='.lock')
        os.close(fd) # We only need this file to exist. We never write to it
        while os.path.exists(lockfile):
            args = [sys.executable] + sys.argv
            environ = os.environ.copy()
            environ['BOTTLE_CHILD'] = 'true'  #标识子进程
            environ['BOTTLE_LOCKFILE'] = lockfile
            p = subprocess.Popen(args, env=environ)
            while p.poll() is None: # Busy wait...  #子进程正常执行中,每隔interval时间更新lockfile的读取时间戳
                os.utime(lockfile, None) # I am alive!
                time.sleep(interval)
            if p.poll() != 3:  # 若子进程返回码不是3,则不是文件改动的重启,就需要彻底关闭了
                if os.path.exists(lockfile): os.unlink(lockfile)
                sys.exit(p.poll())
    except KeyboardInterrupt:
        pass
    finally:
        if os.path.exists(lockfile):
            os.unlink(lockfile)
    return

以上代码中,p.poll()是一个比较重要的操作,它是主进程采集子进程状态的一个方法,用来判断子进程当前的执行状态,如果返回是None,则表示子进程还在执行中,目前并没有返回任何结果。在以上的代码中,子进程执行的过程中,主进程一直在用utime更新着lockfile的时间,这个更新的时间会在后面判断子进程状态时使用。 上面的代码执行完毕之后,主进程就进入了不断检查子进程或在文件做了改动之后重新派生子进程的循环之中。而子进程则没有进到上面的if,继续执行了下去,然后是下面这段代码

if reloader:
    lockfile = os.environ.get('BOTTLE_LOCKFILE')
    bgcheck = FileCheckerThread(lockfile, interval)
    with bgcheck:
        server.run(app)  # 执行到这里就阻塞了,直到bgcheck主动interrupt(发现文件改动,见下)
    if bgcheck.status == 'reload':  # bgcheck发现有文件进行了改动,标记reload,这里子进程退出,并返回code 3,主进程通过这个3再重新派生子进程,如上所述
        sys.exit(3)

这里的with是一个比较巧妙的用法,它确保在文件修改时间检查的同时去启动服务,并在服务终止时做一些收尾工作,就像经典的文件读取一样:

with open('output.txt', 'w') as f:
    f.write('Hi there!')

下面就需要重点关注这个FileCheckerThread了:

class FileCheckerThread(threading.Thread):
    ''' Interrupt main-thread as soon as a changed module file is detected,
        the lockfile gets deleted or gets to old. '''

    def __init__(self, lockfile, interval):
        threading.Thread.__init__(self)
        self.lockfile, self.interval = lockfile, interval
        #: Is one of 'reload', 'error' or 'exit'
        self.status = None

    def run(self):
        exists = os.path.exists  # 函数重命名,可以简化部分大量使用的函数的代码
        mtime = lambda path: os.stat(path).st_mtime
        files = dict()

        for module in list(sys.modules.values()):  # 将所有需要监听的文件都加入检测字典,并记录其最后一次修改的时间
            path = getattr(module, '__file__', '')
            if path[-4:] in ('.pyo', '.pyc'): path = path[:-1]
            if path and exists(path): files[path] = mtime(path)

        while not self.status:
            if not exists(self.lockfile)\
            or mtime(self.lockfile) < time.time() - self.interval - 5:  # 如果lockfile不存在了,或者其已经超过5秒没有更新时间,则是发生了某种错误,标记错误,并interrupt
                self.status = 'error'
                thread.interrupt_main()
            for path, lmtime in list(files.items()):  # 依次检测所有的需要检测的文件的最近修改时间,如果符合重启的条件,则中断调用其的进程(子进程)
                if not exists(path) or mtime(path) > lmtime:
                    self.status = 'reload'
                    thread.interrupt_main()
                    break
            time.sleep(self.interval)

    def __enter__(self):
        self.start()

    def __exit__(self, exc_type, exc_val, exc_tb):
        if not self.status: self.status = 'exit' # silent exit
        self.join()
        return exc_type is not None and issubclass(exc_type, KeyboardInterrupt)

至此就完成了一次重启机制的介绍。🤓

Shellbye avatar May 24 '18 04:05 Shellbye