文章简介
- 一直在用 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
- 代码位置:src/controller/test_controller.py
- 蓝图对外暴露接口的方式有很多,这里展示的是我最喜欢的方式。
- POST 请求 curl
- curl --location 'http://127.0.0.1:5001/test/post' --header 'Content-Type: application/json' --data '{"test": "test"}'
- 用于展示 POST 请求、测试对 ndarray 与 时间类型的 json 编码,以及输出日志
- GET 请求 curl
- curl --location 'http://127.0.0.1:5001/test/current_time?test=tmp'
- 用于展示 GET 请求与测试日志
- 异常测试 curl
- curl --location 'http://127.0.0.1:5001/test/error?test=tmp'
- 用于测试异常的捕获
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 上,后续有能够作为通用能力的功能都会更新在仓库中