Flask生产级模板:统一返回、日志、异常、JSON编解码,开箱即用可扩展

文章简介

  • 一直在用 Python 写一些脚本,最近想通过其他服务调用 Python 脚本,所以用 Flask 搭服务暴露接口让其他服务调用。现在将这个服务中的一些基础能力抽出来作为模版。
  • 模版提供以下能力:
    • 统一返回数据格式
    • 统一为返回数据设置对象的 json 编解码器
    • 日志同时输出至控制台、文件,日志文件自动截断
    • 统一捕获异常信息
  • 代码放在 Github 上,后续有能够作为通用能力的功能都会更新在仓库中
  • 友情提示:不建议直接克隆模版仓库来搭建工程,建议按需复制所需代码,自行搭建

依赖信息

此处的依赖信息可能不全,还请需要的朋友自行查找所需依赖

  • flask:Flask 框架
  • numpy:只用于演示统一的 json 编解码器
  • logging:日志模块

统一返回数据格式

  • 代码位置文件:src/controller/result.py
  • 数据结构:
    • success:接口是否成功
    • result:响应结果
    • message:接口信息响应信息,一般为报错信息
  • 提供两种使用方式
    • 成功返回:Result.success(data) data为返回的数据
    • 失败返回:Result.fail(message) message:为错误信息
python 复制代码
class Result(dict):
    """
    结果对象
    """

    def __init__(self, isSuccess: bool, result: any = None, message: str = None):
        super().__init__()
        self["success"] = "success" if isSuccess else "fail"
        self["result"] = result
        self["message"] = message

    @classmethod
    def success(cls, result: any = None):
        return cls(isSuccess=True, result=result)

    @classmethod
    def fail(cls, message: str = None):
        return cls(isSuccess=False, message=message)

为返回数据设置 python 对象的 json 编码器

  • 代码位置:src/utils/json_codec.py
  • json 编码器提供两种
    • 1、Flask 框架所需的, 使用方式:self.json = DefaultJSONProvider(app),我有继承 Flask 对象,此处的 self 可以替换为Flask 对象
    • 2、json 模块所需的, 使用方式:json.dumps(data, cls=CustomEncoder)
  • 两种编码器都统一使用 codec 函数,其他对象如果也需要处理,可以在 codec 函数添加
python 复制代码
	def codec(obj):
	    """ 自定义编解码器,特殊对象转换 """
	    if isinstance(obj, datetime):
	        # 处理日期时间对象
	        return obj.strftime("%Y-%m-%d %H:%M:%S")
	    elif isinstance(obj, Enum):
	        # 处理枚举对象
	        return obj.name
	    elif isinstance(obj, np.ndarray):
	        # 处理容器类型中的特殊对象
	        return obj.tolist()
	    elif isinstance(obj, np.integer):
	        # 处理numpy整数类型
	        return int(obj)
	    elif isinstance(obj, np.floating):
	        # 处理numpy浮点数类型
	        return float(obj)
	    return None


	class CustomJSONProvider(DefaultJSONProvider):
	    """ 自定义编解码器, 用于flask """

	    def default(self, obj):
	        """处理特殊对象"""
	        codec_result = codec(obj)
	        return codec_result if codec_result else super(CustomJSONProvider, self).default(obj)


	class CustomEncoder(json.JSONEncoder):
	    """ 自定义编解码器,用于 json """

	    def default(self, obj):
	        codec_result = codec(obj)
	        return codec_result if codec_result else super(CustomEncoder, self).default(obj)

日志同时输出至控制台、文件

代码位置:src/config/log_config.py

  • 实现方式:将 sys.stdout 和 sys.stderr 重定向至日志配置对象,日志对象实现 write 和 flush 函数, 在 write 函数中通过 logging 模块将日志写入文件,在此处对写入的文件进行格式化,添加线程信息。每 30s 检查一次文件大小,超过 100M 时将前 99M 数据清掉。
  • 在 write 函数中也将数据输入一份至控制台
  • 使用方式:log_config(name=import_name, log_filename=log_filename)
python 复制代码
	class LogConfig:

	    def __init__(self, name: any, log_type: str, log_file: str):
	        self.log_file = log_file
	        self.log_type = log_type
	        self.max_size = 100 * 1024 * 1024
	        self.trim_size = 99 * 1024 * 1024
	        self._last_check = time.time()
	        # 为 LogWriter 创建专用的日志记录器
	        self.logger = logging.getLogger(name)
	        self.logger.setLevel(logging.INFO)
	        # 避免重复添加处理器
	        if not self.logger.handlers:
	            handler = logging.FileHandler(log_file)
	            # %(asctime)s: 时间戳
	            # %(message)s: 日志消息
	            formatter = logging.Formatter('%(asctime)s - %(message)s')
	            handler.setFormatter(formatter)
	            self.logger.addHandler(handler)

	    def check_and_trim_file(self):
	        """检查文件大小并在必要时截断"""
	        if os.path.exists(self.log_file):
	            file_size = os.path.getsize(self.log_file)
	            if file_size > self.max_size:
	                try:
	                    with open(self.log_file, 'rb') as f:
	                        f.seek(self.trim_size)
	                        remaining_data = f.read()

	                    with open(self.log_file, 'wb') as f:
	                        f.write(remaining_data)
	                except Exception as e:
	                    # 如果截断失败,继续正常写入
	                    pass

	    def write(self, message):
	        if message.strip():  # 忽略空行
	            # 减少文件大小检查频率
	            current_time = time.time()
	            if (current_time - self._last_check) > 30:
	                # 检查是否需要截断文件
	                self.check_and_trim_file()
	                self._last_check = current_time

	            # 创建日志记录器并手动记录
	            thread_name = threading.current_thread().name
	            formatted_message = f"{thread_name} - {message.strip()}"
	            self.logger.info(formatted_message)
	            # 获取当前时间戳
	            timestamp = time.time()
	            # 转换为本地时间结构
	            time_struct = time.localtime(timestamp)
	            # 格式化时间
	            formatted_time = time.strftime('%Y-%m-%d %H:%M:%S', time_struct)
	            if 'err' in self.log_type:
	                sys.__stderr__.write(f'{formatted_time} - {formatted_message}\n')
	            else:
	                sys.__stdout__.write(f'{formatted_time} - {formatted_message}\n')

	    def flush(self):
	        if 'err' in self.log_type:
	            sys.__stderr__.flush()
	        else:
	            sys.__stdout__.flush()


	def log_config(name: any, log_filename: str):
	    """配置日志"""
	    try:
	        # 创建日志写入器=
	        # 重定向 print 输出
	        sys.stdout = LogConfig(name=name, log_type='out', log_file=log_filename)
	        sys.stderr = LogConfig(name=name, log_type='err', log_file=log_filename)
	    except Exception as e:
	        traceback.print_exc()
	        # 如果重定向失败,至少保证程序能正常运行
	        print(f"日志重定向配置失败: {e}")

统一捕获异常信息

代码位置:src/application.py

  • 使用方式:self.errorhandler(Exception)(self.handle_exception),此处的 self 也是 Flask 对象
  • 捕获异常后打印堆栈信息,之后返回我们定义好的响应失败对象,并且返回 http 状态码为:500
python 复制代码
	def handle_exception(self, ex: Exception):
	    """处理未捕获的异常"""
	    traceback.print_exc()
	    # 返回自定义错误响应
	    return Result.fail(f'{ex}'), 500

统一添加请求、响应日志

代码位置:src/application.py 使用方法:

  • 添加请求前处理逻辑 self.before_request(self.log_request_info)
  • 添加响应后处理逻辑 self.after_request(self.process_response)
python 复制代码
    def log_request_info(self):
        """记录请求信息"""
        self.request_duration_local.start_time = datetime.now()
        path_log = f'{'=' * 40} 请求路径: {request.path} {'=' * 40}'
        headers_log = '\n'.join([f'{key}: {value}' for key, value in request.headers.items()])
        request_body = request.get_json() if request.is_json else None
        print(
            f'请求信息\n'
            f'{path_log}\n'
            f'请求方法: {request.method}\n'
            f'{headers_log}\n'
            f'请求参数/args: {json.dumps(request.args, ensure_ascii=False)}\n'
            f'请求体/json: {json.dumps(request_body, ensure_ascii=False)}\n'
            f'{'=' * (len(path_log) + 3)}')

    def process_response(self, response):
        """记录响应信息"""
        duration = datetime.now() - self.request_duration_local.start_time
        path_log = f'{'=' * 40} 响应信息: {request.path} {'=' * 40}'
        if response.is_json:
            resp_result = json.dumps(response.get_json(), ensure_ascii=False)
        else:
            resp_result = response.data
        if len(resp_result) > 1024:
            resp_result = f'{resp_result[0:256]}...{resp_result[-256:]}'
        print(
            f'响应信息\n'
            f'{path_log}\n'
            f'请求路径: {request.path} 请求方法: {request.method}\n'
            f'响应状态码: {response.status_code} 耗时: {duration.total_seconds():.3f}s\n'
            f'响应结果: {resp_result}\n'
            f'{'=' * (len(path_log) + 3)}')
        return response

测试 controller

python 复制代码
"""
创建蓝图
"""
test_bp = Blueprint("test", __name__, url_prefix="/test")


@test_bp.post("post")
def test_post():
    """
    测试post请求
    """
    request_body = request.json
    data = {
        "request_body": request_body,
        "request_time": datetime.now(),
        "ndarray": np.array([1, 2, 3]),
    }
    print(data)
    return Result.success(data)


@test_bp.get("current_time")
def current_time():
    """
    测试get请求
    """
    args = request.args
    data = {
        "args": args,
        "request_time": datetime.now(),
    }
    print(data)
    return Result.success(data)


@test_bp.get("error")
def test_error():
    """
    测试异常
    """
    raise Exception("测试异常")

配置 CROS 跨域并注册蓝图

  • 对所有请求、所有类型和所有请求方式配置 CROS 跨域,或者自定义跨域方式
  • 对每一个蓝图进行注册
  • 备注:需要对所有蓝图单独配置 CROS
python 复制代码
    def set_cors_and_register_blueprint(self, bp: Blueprint, cors_config: dict = None):
        """
        配置跨域并注册蓝图
        """
        if cors_config is None:
            cors_config = {
                "origins": "*",
                "allow_headers": ["Content-Type"],
                "methods": ["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"]
            }

        # 跨域配置
        print(f'跨域配置: {bp.name}-{json.dumps(cors_config)}')
        CORS(bp, **cors_config)
        # 注册蓝图
        print(f'注册蓝图: {bp.name}')
        self.register_blueprint(bp)

继承后的 FlaskApp 对象

  • 因为很多配置都需要操作 Flask 对象,索性继承 Flask 对象,把所有配置集成到一起
  • 代码中有注释,不过多赘述
  • FlaskApp 对象的使用在示例代码最下放,也就是入口函数中使用
  • platform.system() == 'Darwin':由于我使用 Mac 进行开发,所以此处设置为当系统为 Mac 时开启 Debug 模式
  • 备注:每一个蓝图都需要单独进行注册,我的习惯是每个 controller 分配一个蓝图,每增加一个 controller 在这里加一行
python 复制代码
class FlaskApp(Flask):
    def __init__(self, import_name: str, log_filename: str, **kwargs):
        super().__init__(import_name, **kwargs)
        # 日志配置
        log_config(name=import_name, log_filename=log_filename)

        # 注册蓝图
        self.blueprints_to_register = []
        from src.controller.test_controller import test_bp
        self.add_blueprint(bp=test_bp)

        # 添加请求前和响应后处理逻辑
        self.before_request(self.log_request_info)
        self.after_request(self.process_response)

        # 添加错误处理
        self.errorhandler(Exception)(self.handle_exception)

        # 请求耗时统计
        self.request_duration_local = threading.local()

        # 自定义编解码器
        self.json = CustomJSONProvider(self)

    def log_request_info(self):
        """记录请求信息"""
        self.request_duration_local.start_time = datetime.now()
        path_log = f'{'=' * 40} 请求路径: {request.path} {'=' * 40}'
        headers_log = '\n'.join([f'{key}: {value}' for key, value in request.headers.items()])
        request_body = request.get_json() if request.is_json else None
        print(
            f'请求信息\n'
            f'{path_log}\n'
            f'请求方法: {request.method}\n'
            f'{headers_log}\n'
            f'请求参数/args: {json.dumps(request.args, ensure_ascii=False)}\n'
            f'请求体/json: {json.dumps(request_body, ensure_ascii=False)}\n'
            f'{'=' * (len(path_log) + 3)}')

    def process_response(self, response):
        """记录响应信息"""
        duration = datetime.now() - self.request_duration_local.start_time
        path_log = f'{'=' * 40} 响应信息: {request.path} {'=' * 40}'
        if response.is_json:
            resp_result = json.dumps(response.get_json(), ensure_ascii=False)
        else:
            resp_result = response.data
        if len(resp_result) > 1024:
            resp_result = f'{resp_result[0:256]}...{resp_result[-256:]}'
        print(
            f'响应信息\n'
            f'{path_log}\n'
            f'请求路径: {request.path} 请求方法: {request.method}\n'
            f'响应状态码: {response.status_code} 耗时: {duration.total_seconds():.3f}s\n'
            f'响应结果: {resp_result}\n'
            f'{'=' * (len(path_log) + 3)}')
        return response

    def handle_exception(self, ex: Exception):
        """处理未捕获的异常"""
        traceback.print_exc()
        # 返回自定义错误响应
        return Result.fail(f'{ex}'), 500

    def add_blueprint(self, bp: Blueprint):
        """添加需要注册的蓝图"""
        self.blueprints_to_register.append(bp)

    def set_cors_and_register_blueprint(self, bp: Blueprint, cors_config: dict = None):
        """
        配置跨域并注册蓝图
        """
        if cors_config is None:
            cors_config = {
                "origins": "*",
                "allow_headers": ["Content-Type"],
                "methods": ["GET", "PUT", "POST", "DELETE", "OPTIONS", "PATCH"]
            }

        # 跨域配置
        print(f'跨域配置: {bp.name}-{json.dumps(cors_config)}')
        CORS(bp, **cors_config)
        # 注册蓝图
        print(f'注册蓝图: {bp.name}')
        self.register_blueprint(bp)

    def run(self, host: str | None = None, port: int | None = None,
            debug: bool | None = None, load_dotenv: bool = True, **options: t.Any) -> None:

        for bp in self.blueprints_to_register:
            self.set_cors_and_register_blueprint(bp)

        super().run(host=host, port=port, debug=debug, load_dotenv=load_dotenv, **options)


if __name__ == "__main__":
    log_filename = './python.log'
    app = FlaskApp(__name__, log_filename)
    app.run(host="0.0.0.0", port=5001, debug=platform.system() == 'Darwin')

开源仓库

代码放在 Github 上,后续有能够作为通用能力的功能都会更新在仓库中

相关推荐
yy我不解释5 小时前
关于comfyui的comfyui-prompt-reader-node节点(import failed)和图片信息问题(metadata)
python·ai作画·prompt
我是你们的明哥5 小时前
从 N 个商品中找出总价最小的 K 个方案
后端·算法
骑着bug的coder5 小时前
第4讲:现代SQL高级特性——窗口函数与CTE
后端
BoBoZz195 小时前
SmoothDiscreteMarchingCubes 多边形网格数据的平滑
python·vtk·图形渲染·图形处理
Dwzun5 小时前
基于SpringBoot+Vue的农产品销售系统【附源码+文档+部署视频+讲解)
数据库·vue.js·spring boot·后端·毕业设计
y1y1z5 小时前
Spring Security教程
java·后端·spring
小橙编码日志5 小时前
分布式系统推送失败补偿场景【解决方案】
后端·面试
XiaoMu_0015 小时前
多场景头盔佩戴检测
人工智能·python·深度学习
程序员根根5 小时前
Maven 核心知识点(核心概念 + IDEA 集成 + 依赖管理 + 单元测试实战)
后端