摘要
经过上一章节的重构之后,我们已经把设备和命令的增删改查成功的集成到了Flask应用中,并且后端应用也具备了大型项目的雏形。
今天的章节则会对应用做更进一步的完善和延伸处理,希望大家可以从中学到如何把自己的代码变得能够在生产环境中安全稳定的运行。
路由函数
首先需要实现设备和命令的增删改查功能的路由函数,代码如下:
# /application/views/action.py
from flask import Blueprint, request
from ..models import db
from ..services import ActionORMHandler
action_blueprint = Blueprint("action", __name__, url_prefix="/action")
@action_blueprint.route("/add", methods=["POST"])
def add():
data = request.get_json()
ActionORMHandler(db.session()).add(data)
return "success"
@action_blueprint.route("delete", methods=["POST"])
def delete():
data = request.get_json()
ActionORMHandler(db.session()).delete(data)
return "success"
@action_blueprint.route("update", methods=["POST"])
def update():
data = request.get_json()
ActionORMHandler(db.session()).update(data)
return "success"
@action_blueprint.route("/get")
def get():
args = request.args.to_dict()
res = ActionORMHandler(db.session()).get(args)
return [item.to_dict() for item in res]
# /application/views/cmdb.py
from flask import Blueprint, request
from ..models import db
from ..services import DeviceORMHandler
cmdb_blueprint = Blueprint("cmdb", __name__, url_prefix="/cmdb")
@cmdb_blueprint.route("/add", methods=["POST"])
def add():
data = request.get_json()
DeviceORMHandler(db.session()).add(data)
return "success"
@cmdb_blueprint.route("/delete", methods=["POST"])
def delete():
data = request.get_json()
DeviceORMHandler(db.session()).delete(data)
return "success"
@cmdb_blueprint.route("/update", methods=["POST"])
def update():
data = request.get_json()
DeviceORMHandler(db.session()).update(data)
return "success"
@cmdb_blueprint.route("/get")
def get():
res = DeviceORMHandler(db.session()).get()
return [item.to_dict() for item in res]
设备和命令的相关路由函数非常简单,只需要调用services中提供的DeviceHandler和ActionHandler即可,这也是把代码合理分层的最重要原因——让代码变得更具有易读性和可维护性。
下面注册SSH执行器相关的路由函数,代码如下:
# /application/views/executor.py
from flask import Blueprint, request, current_app
from ..models import db, Action
from ..services import DeviceORMHandler, ActionORMHandler, SSHExecutor
executor_blueprint = Blueprint("executor", __name__, url_prefix="/executor")
@executor_blueprint.route("/prompt", methods=["POST"])
def get_prompt():
data = request.get_json()
device_handler = DeviceORMHandler(db.session())
action_handler = ActionORMHandler(db.session())
ssh_executor = SSHExecutor(
username=current_app.config.get("SSH_USERNAME"),
password=current_app.config.get("SSH_PASSWORD"),
secret=current_app.config.get("SSH_SECRET"),
device_condition=data.get("device_condition"),
device_handler=device_handler,
action_handler=action_handler,
logger=current_app.logger)
prompt = ssh_executor.conn.base_prompt
ssh_executor.close()
return prompt
上述代码中,创建了新的蓝图,将与执行器有关的路由都注册到executor_blueprint上;
除此之外我将需要注意的地方高亮了出来:
-
路由函数允许接受的方法:methods=[“POST”]
-
获取JSON类型的body请求体:request.get_json()
-
获取配置信息:current_app.config.get(“”)
-
获取已注册的logger(下文提到):current_app.logger
接口测试
之前的章节中提到过可以使用postman类型的插件或扩展进行接口测试,这一章节我们用另外一种方式进行测试;
如果使用的是PyCharm的话,可以在目录里新建后缀为.http的文件,内容如下:
# api.http
POST http://127.0.0.1:5000/executor/prompt
Content-Type: application/json
{"device_condition": {"ip": "192.168.31.149"}}
###
可以点击左侧的绿色箭头直接出发http请求,其中Content-Type是http请求里body的类型,该类型需要和后端保持一致。
异常处理
一个部署在生产环境的项目,就一定需要有完整的异常处理;
Flask中的内部异常继承的是HTTPException这个异常类,这个异常类来自Werkzeug;
Werkzeug不是一个框架,它是一个 WSGI 工具集的库,你可以通过它来创建你自己的框架或 Web 应用,之前的章节中提到Web框架必须符合WSGI标准协议,而Flask就是借助Werkzeug来作为实现WSGI标准的底层库,自己再此之上构建Web框架,关系图如下:
HTTPException的源码大致如下:
class HTTPException(Exception):
code: t.Optional[int] = None
description: t.Optional[str] = None
def __init__(
self,
description: t.Optional[str] = None,
response: t.Optional["Response"] = None,
) -> None:
super().__init__()
if description is not None:
self.description = description
self.response = response
def get_body(
self,
environ: t.Optional["WSGIEnvironment"] = None,
scope: t.Optional[dict] = None,
) -> str:
"""Get the HTML body."""
return (
"<!doctype html>\n"
"<html lang=en>\n"
f"<title>{self.code} {escape(self.name)}</title>\n"
f"<h1>{escape(self.name)}</h1>\n"
f"{self.get_description(environ)}\n"
)
def get_headers(
self,
environ: t.Optional["WSGIEnvironment"] = None,
scope: t.Optional[dict] = None,
) -> t.List[t.Tuple[str, str]]:
"""Get a list of headers."""
return [("Content-Type", "text/html; charset=utf-8")]
def get_response(
self,
environ: t.Optional[t.Union["WSGIEnvironment", "WSGIRequest"]] = None,
scope: t.Optional[dict] = None,
) -> "Response":
if self.response is not None:
return self.response
if environ is not None:
environ = _get_environ(environ)
headers = self.get_headers(environ, scope)
return WSGIResponse(self.get_body(environ, scope), self.code, headers)
可以发现其中有几个最为重要的函数,分别是get_body,get_headers, get_response;get_response返回的是一个WSGIResponse的对象,它需要传入body,code, headers。
所以如果我们想实现一个自定义的异常类,那就只需要继承HTTPException,并且实现get_body,get_headers,并支持自定义code就可以了,代码如下:
# /application/exception.py
import json
from typing import List, Tuple
from flask import request
from werkzeug.exceptions import HTTPException
class APIException(HTTPException):
code = 500
message = 'API Exception'
data = None
def __init__(self, code=None, message=None, data=None):
if code is not None:
self.code = code
if message is not None:
self.message = message
if data is not None:
self.data = data
super(APIException, self).__init__(self.message, None)
def get_body(self, environ=None, scope=None) -> str:
body = {
"data": self.data,
"status_code": self.code,
"message": self.message,
"request": request.method + ' ' + self.get_url_without_param()
}
return json.dumps(body)
def get_headers(self, environ=None, scope=None) -> List[Tuple[str, str]]:
return [('Content-Type', 'application/json')]
@staticmethod
def get_url_without_param() -> str:
full_url = str(request.full_path)
return full_url.split('?')[0]
原本的HTTPException中返回的是html文本,但我们的项目是以API的方式提供服务,所以后端的返回统一是JSON字符串,因此需要重写get_headers方法,将其返回类型改为application/json,并且重写get_body方法,返回自定义的JSON字符串即可;初始化方法则改为可以接收code,message,data三个参数。
经过上述的改造就实现了一个自定义的异常类,异常类具体的使用方法如下:
先定义几个常见的异常类继承自APIException
# /application/exception.py
class Success(APIException):
code = 200
message = "success"
def __init__(self, data=None):
super().__init__(self.code, self.message, data)
class ServerError(APIException):
code = 500
message = "server error"
class DBError(APIException):
code = 510
message = "db error"
路由函数中抛出自定义异常类
# /application/views/action.py
from ..exception import Success, DBError
@action_blueprint.route("/add", methods=["POST"])
def add():
data = request.get_json()
try:
ActionORMHandler(db.session()).add(data)
return Success()
except Exception as e:
raise DBError(message=str(e))
@action_blueprint.route("/get")
def get():
args = request.args.to_dict()
try:
res = ActionORMHandler(db.session()).get(args)
return Success(data=[item.to_dict() for item in res])
except Exception as e:
raise DBError(message=str(e))
日志
大型项目除了完善的异常处理,还有一个必不可少的就是日志记录,无论是在关键的逻辑处理地方主动打印的日志,还是意料之外的异常日志都需要记录下来
首先需要将日志的对象注册到app上,代码如下:
# application/__init__.py
import logging
from logging.handlers import RotatingFileHandler
def create_app(env: str = "dev") -> Flask:
app = Flask(__name__)
# register configuration
# ...
# register db
# ...
# register blueprint
# ...
# register logging
register_logging(app)
return app
def register_logging(app):
formatter = logging.Formatter(
'%(asctime)s %(levelname)s P[%(process)d] T[%(thread)d] %(lineno)sL@%(filename)s:'
' %(message)s')
handler = RotatingFileHandler("flask.log", maxBytes=1024000, backupCount=10)
handler.setLevel(app.config.get("LOG_LEVEL"))
handler.setFormatter(formatter)
app.logger.addHandler(handler)
@app.before_request
def log_each_request():
app.logger.info(f"[{request.method}]{request.path} from {request.remote_addr}, params {request.args.to_dict()}, body {request.get_data()}")
上述代码中的register_logging函数中对日志做了一定的配置,包括通过formatter定义日志格式,通过RotatingFileHandler定义了根据数据大小进行切分的日志文件,并且设置了日志的打印级别,最后将日志对象添加到了app.logger上。
除此之外我们还希望将每次请求的信息记录下,诸如:请求的方法,路径,参数等;上述代码中用@app.before_request装饰的log_each_request就可以实现这个功能,该装饰器会在执行路由函数之前执行被装饰的函数,具体细节会在视频讲解中提到。
注册完日志对象后,可以通过如下方式记录日志:
# /application/views/action.py
from flask import current_app
@action_blueprint.route("/add", methods=["POST"])
def add():
data = request.get_json()
try:
ActionORMHandler(db.session()).add(data)
current_app.logger.success("add success")
return Success()
except Exception as e:
raise DBError(message=str(e))
此时访问该路由函数后,日志文件中会多一条记录:
2022-11-29 15:08:24,829 SUCCESS P[87068] T[123145357492224] 35L@action.py: add success
除了主动记录的日志之外,还有程序意外抛出的异常需要记录,那么这就需要对整个后端应用做一个try…except,这一点Flask已经考虑到了,并且也已经做了,我们只需要在这个地方使用刚才自定义的日志对象记录错误信息即可。
# application/exception.py
import traceback
def register_errors(app: Flask):
@app.errorhandler(Exception)
def framework_error(e):
app.logger.error(str(e))
app.logger.error(traceback.format_exc())
if isinstance(e, APIException): # 手动触发的异常
return e
elif isinstance(e, HTTPException): # 代码异常
return APIException(e.code, e.description, None)
else:
if app.config['DEBUG']:
raise e
else:
return ServerError()
# application/__init__.py
from application.exception import register_error
def create_app(env: str = "dev") -> Flask:
app = Flask(__name__)
# register configuration
# ...
# register db
# ...
# register blueprint
# ...
# register logging
# ...
register_errors(app)
return app
上下文(Context)
大家应该在最近的几个章节中可以频繁的看到,request、current_app这样的变量,这个就涉及到了Flask的上下文,也是Flask中比较难理解的部分,但对于初期的使用上来说,对这个概念是否理解并不会很大的影响应用的构建。
上下文顾名思义就是与某处相关的内容,那么代码中的上下文就是指与某处代码相关的变量或对象。
上下文管理器
提起上下文可能很多朋友会想到上下文管理器,这个是Python中较高级的特性,理论上属于一种语法糖;但上下文管理器和Flask中的上下文并无关联,这里只是顺便讲解一下。
我们可以通过改造SSHExecutor来给大家讲解一下如何使用上下文管理器,原本使用SSHExecutor的代码如下所示:
ssh_executor = SSHExecutor(...)
output = ssh_executor.execute(...)
ssh_executor.close()
通过上下文管理器来重构的话就可以变成如下所示:
with SSHExecutor(...) as ssh:
output = ssh.execute(...)
上述代码的作用就是可以在SSHExecutor创建和结束时执行相应的操作,比如结束时我们想自动关闭连接,而不是手动调用close()方法;通过这种方式就可以将SSHExecutor的上下文管理起来,故叫做上下文管理器。
很明显with…as…作为一种语法糖并不是可以想用就用的,因为Python不可能自动的识别某个对象在创建和结束时想执行的操作,所以需要我们基于要管理的对象来实现某些方法,代码如下:
class SSHExecutor:
...
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
if exc_type:
self.logger.error(exc_type)
self.logger.error(exc_val)
self.logger.error(exc_tb)
self.close()
如上述代码所示,执行with … as …语句时,会初始化SSHExecutor并调用__enter__方法,将该方法的返回值赋值给as后的变量(这里其实相当于直接初始化了一个SSHExecutor实例,但如果有必要还可以自定义其他操作);
当执行完with语句块内的内容后,就会自动调用__exit__方法结束上下文管理器,exc_type、exc_val、exc_tb变量的含义分别为:异常类型、异常值、异常堆栈;这样我们就可以在结束SSHExecutor的操作之后自动调用self.close()方法关闭连接,如果with语句块中有异常也可以进行相应的处理。
Python也有内置模块contextlib中提供一些装饰器实现相关的功能,但原理都是相同的。
Flask上下文
在Flask中,应用从客户端收到请求的时候,视图函数如果要处理请求,可能就要访问一些相关的对象,比如有关此次请求的各种属性,或者有关app的相关变量,这些就统称为Flask的上下文。
如果要清晰的解释Flask上下文,就必须要涉及到源码的解读,但考虑到部分源码讲解起来较难理解,反而会导致刚接触Flask的朋友更为困惑,所以我这里将上下文的原理通俗易懂的解释一下
Flask中的上下文分为两种:
-
请求上下文:request,session
-
应用上下文:current_app,g
作为全局变量
不管是请求上下文还是应用上下文都是全局变量,可以直接通过import的方式引入,代码如下:
from flask import request, session, current_app, g
全局变量的好处就是可以不用将其作为参数传递,示例如下:
# a.py
from flask import request, g
from utils import test
@app.route("/index")
def index():
print(request.args)
g.username = 'ethan'
test()
# b.py
from flask import request, g
def test():
print(request.method)
print(g.username)
如上述代码所示,在路由函数中调用utils文件中的test()函数,两个函数中都想打印请求的method,但不需要将request作为参数传递,而是分别在两个文件中直接导入request即可;current_app、g、session同理。
线程隔离
请求上下文或应用上下文在处理多个的请求时互相不会干扰,因为Flask内部将其处理为线程隔离的对象,大致实现的方式可以理解为将线程ID作为字典的键,存储的上下文内容作为值,跟路由区分不同蓝图的实现原理很类似。
生命周期和存储内容
请求上下文和应用上下文都是有生命周期的,他们都伴随请求创建,并在请求结束后销毁。
-
request:接收到请求后创建,存储了此次请求的相关信息,如请求头,请求体等
-
session:与request同时创建,默认加载请求中的cookies内容,否则创建一个空的session
-
current_app:接收到请求后创建,但稍晚于request,等同于此刻的app对象,可以完美解决对app的循环引用,
-
g:与current_app同时创建,默认没有存储值,可以用来存储自定义内容,用法如上述代码中所示,
所有的上下文都会在请求结束后销毁,但session与其他的上下文有一个不同的就是,Flask会在销毁session前默认将session的内容写入到返回体的cookie中(可以新增一个配置项:SESSION_REFRESH_EACH_REQUEST,并将其设为True,则不会去更新返回体的cookie)
总结
这一章节结束后Flask大型应用的构建就已经结束了,从文件拆分,到路由蓝图,再加上环境配置、异常处理,最后完成和SSH执行器的结合。现在已经具备了将远程CLI进行服务化的基本能力。
文章中所讲的内容只是我给大家提供一个基本的思路,以此来完成各个知识点的串联,以及对大家编程思维的培养,大家不必完全局限于文章中所讲,可以结合已有的编程知识尽情发挥