kksanic 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- kksanic/__init__.py +187 -0
- kksanic/__main__.py +10 -0
- kksanic/access_log.py +493 -0
- kksanic/admin.py +1525 -0
- kksanic/admin_page.py +2565 -0
- kksanic/admin_server.py +924 -0
- kksanic/auth.py +402 -0
- kksanic/cache/__init__.py +54 -0
- kksanic/cache/base.py +119 -0
- kksanic/cache/memory.py +151 -0
- kksanic/cache/redis.py +441 -0
- kksanic/cli/__init__.py +14 -0
- kksanic/cli/__main__.py +5 -0
- kksanic/cli/main.py +742 -0
- kksanic/cli/scaffold.py +813 -0
- kksanic/cli/templates.py +525 -0
- kksanic/cli/templates_en.py +319 -0
- kksanic/doc.py +1833 -0
- kksanic/fields.py +987 -0
- kksanic/i18n.py +371 -0
- kksanic/locale/__init__.py +5 -0
- kksanic/locale/en.py +453 -0
- kksanic/model.py +518 -0
- kksanic/permission.py +1676 -0
- kksanic/request.py +192 -0
- kksanic/response.py +158 -0
- kksanic/restful.py +1965 -0
- kksanic/serialize.py +154 -0
- kksanic/serializer.py +766 -0
- kksanic/shard.py +802 -0
- kksanic/swagger.py +332 -0
- kksanic/token_store.py +346 -0
- kksanic/user_cache.py +539 -0
- kksanic/utils.py +997 -0
- kksanic/validator.py +3631 -0
- kksanic-0.1.0.dist-info/METADATA +217 -0
- kksanic-0.1.0.dist-info/RECORD +41 -0
- kksanic-0.1.0.dist-info/WHEEL +5 -0
- kksanic-0.1.0.dist-info/entry_points.txt +2 -0
- kksanic-0.1.0.dist-info/licenses/LICENSE +21 -0
- kksanic-0.1.0.dist-info/top_level.txt +1 -0
kksanic/__init__.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""kksanic —— 基于 Sanic + Tortoise 的小工具箱。
|
|
2
|
+
|
|
3
|
+
包根只导出最常用的入口,其余按模块取:
|
|
4
|
+
|
|
5
|
+
from kksanic import Request # 等价于 from kksanic.request import Request
|
|
6
|
+
from kksanic import Validator, Rule # 等价于 from kksanic.validator import ...
|
|
7
|
+
from kksanic import ApiException # 等价于 from kksanic.response import ...
|
|
8
|
+
from kksanic import BaseModel, AbstractUser # 等价于 from kksanic.model import ...
|
|
9
|
+
|
|
10
|
+
规则默认建议按字段类型选子类(``StringRule`` / ``IntRule`` / ``FloatRule`` / ``FileRule`` ...),
|
|
11
|
+
写错维度会在定义期报 ``TypeError``;``Rule`` 是「什么都能写」的基类,两者可以混用::
|
|
12
|
+
|
|
13
|
+
from kksanic import StringRule, IntRule
|
|
14
|
+
|
|
15
|
+
class UserValidator(Validator):
|
|
16
|
+
name: Annotated[str, '名称', StringRule(min_length=3, max_length=20)]
|
|
17
|
+
age: Annotated[int, '年龄', IntRule(min_value=0, max_value=150)]
|
|
18
|
+
|
|
19
|
+
命令行侧(``python -m kksanic`` / 装好后直接 ``kksanic``)::
|
|
20
|
+
|
|
21
|
+
kksanic init [--db postgres] # 生成 Sanic + Tortoise 骨架
|
|
22
|
+
kksanic doc [--app main:app] # 返回 JSON 的接口 → Markdown + OpenAPI +
|
|
23
|
+
# 自带 Swagger UI 的 api.html
|
|
24
|
+
|
|
25
|
+
运行期把同一份页面挂成 ``/swagger``(带登录框,登录后 Try it out 就带票)::
|
|
26
|
+
|
|
27
|
+
from kksanic import add_swagger_ui
|
|
28
|
+
|
|
29
|
+
add_swagger_ui(app) # GET /swagger(路由都注册完之后再调)
|
|
30
|
+
kksanic admin [--app main:app] # 挂了 @admin 的接口 → 单文件后台管理站
|
|
31
|
+
|
|
32
|
+
生成出来是静态文件,跑起来要再挂一次(一行,放在所有 blueprint 之后)::
|
|
33
|
+
|
|
34
|
+
from kksanic import add_admin_site
|
|
35
|
+
|
|
36
|
+
add_admin_site(app, 'admin') # GET /admin(软删开着时自带回收站接口)
|
|
37
|
+
|
|
38
|
+
页面默认用 ``settings.LANG`` 的语言;侧栏底部(``minimal`` 风格在顶栏右侧)
|
|
39
|
+
和登录页右上角还各有一个切换器,使用者可以自己切,选过的记在浏览器里。
|
|
40
|
+
|
|
41
|
+
``doc`` 的实现在 :mod:`kksanic.doc`,``admin`` 的实现在 :mod:`kksanic.admin`,
|
|
42
|
+
挂载与回收站接口在 :mod:`kksanic.admin_server`。包根只导出装饰器
|
|
43
|
+
``admin`` / ``Admin`` 和 ``add_admin_site``(写接口时不用再 import 子模块);
|
|
44
|
+
真正扫路由表的动作(``kksanic doc`` / ``kksanic admin``)由命令行按需触发。
|
|
45
|
+
|
|
46
|
+
库被装进 ``site-packages`` 后无法从自身位置推断使用者的**项目根**,所以
|
|
47
|
+
``<项目根>/settings.py``、``<项目根>/media`` 这类路径要靠运行时探测。
|
|
48
|
+
探测链覆盖不到的部署方式(典型是 ``sanic server:app``),在启动代码里
|
|
49
|
+
显式指定一次即可:
|
|
50
|
+
|
|
51
|
+
import kksanic
|
|
52
|
+
kksanic.set_project_root('/path/to/your/project')
|
|
53
|
+
|
|
54
|
+
也可以用环境变量 ``KKSANIC_PROJECT_ROOT``。
|
|
55
|
+
|
|
56
|
+
Note:
|
|
57
|
+
导入 ``Validator`` 会连带把 ``tortoise`` 拉进来(校验器要读模型元信息生成字段),
|
|
58
|
+
所以包根现在同时依赖 ``sanic`` 与 ``tortoise``。
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
__version__ = '0.1.0'
|
|
62
|
+
|
|
63
|
+
from .access_log import add_request_logging
|
|
64
|
+
from .i18n import get_lang, is_english, tr
|
|
65
|
+
from .request import Request
|
|
66
|
+
from .response import ApiException, get_response, register_exception_handler
|
|
67
|
+
from .serializer import ModelSerializer, SerializerTypeError
|
|
68
|
+
from .utils import (
|
|
69
|
+
ADMIN_STYLES,
|
|
70
|
+
as_bool,
|
|
71
|
+
get_admin_settings,
|
|
72
|
+
get_datetime_settings,
|
|
73
|
+
get_project_root,
|
|
74
|
+
get_request_log_settings,
|
|
75
|
+
get_restful_settings,
|
|
76
|
+
is_soft_delete_enabled,
|
|
77
|
+
set_project_root,
|
|
78
|
+
)
|
|
79
|
+
from .validator import (
|
|
80
|
+
BoolRule,
|
|
81
|
+
DateRule,
|
|
82
|
+
DateTimeRule,
|
|
83
|
+
DictRule,
|
|
84
|
+
File,
|
|
85
|
+
FileRule,
|
|
86
|
+
FloatRule,
|
|
87
|
+
IntRule,
|
|
88
|
+
ListRule,
|
|
89
|
+
ModelRule,
|
|
90
|
+
Optional,
|
|
91
|
+
PaginationValidator,
|
|
92
|
+
Rule,
|
|
93
|
+
StringRule,
|
|
94
|
+
ValidateError,
|
|
95
|
+
Validator,
|
|
96
|
+
)
|
|
97
|
+
from .model import AbstractUser, BaseModel
|
|
98
|
+
from .restful import (
|
|
99
|
+
CreateConfig,
|
|
100
|
+
DeleteConfig,
|
|
101
|
+
DetailConfig,
|
|
102
|
+
ListConfig,
|
|
103
|
+
ListMode,
|
|
104
|
+
Method,
|
|
105
|
+
MethodConfig,
|
|
106
|
+
UpdateConfig,
|
|
107
|
+
add_rest_ful_apis,
|
|
108
|
+
add_restful_apis,
|
|
109
|
+
get_or_404,
|
|
110
|
+
get_paging_settings,
|
|
111
|
+
)
|
|
112
|
+
from .admin import Admin, admin
|
|
113
|
+
from .admin_server import add_admin_site
|
|
114
|
+
from .shard import (
|
|
115
|
+
ShardError,
|
|
116
|
+
ShardedModel,
|
|
117
|
+
ShardRule,
|
|
118
|
+
expand,
|
|
119
|
+
is_shard,
|
|
120
|
+
root_of,
|
|
121
|
+
shards_of,
|
|
122
|
+
)
|
|
123
|
+
from .swagger import SwaggerMount, add_swagger_ui
|
|
124
|
+
|
|
125
|
+
__all__ = [
|
|
126
|
+
'Request',
|
|
127
|
+
'add_request_logging',
|
|
128
|
+
'add_rest_ful_apis',
|
|
129
|
+
'add_restful_apis',
|
|
130
|
+
'Method',
|
|
131
|
+
'ListMode',
|
|
132
|
+
'MethodConfig',
|
|
133
|
+
'CreateConfig',
|
|
134
|
+
'ListConfig',
|
|
135
|
+
'DetailConfig',
|
|
136
|
+
'UpdateConfig',
|
|
137
|
+
'DeleteConfig',
|
|
138
|
+
'get_restful_settings',
|
|
139
|
+
'get_paging_settings',
|
|
140
|
+
'get_request_log_settings',
|
|
141
|
+
'get_admin_settings',
|
|
142
|
+
'get_datetime_settings',
|
|
143
|
+
'ADMIN_STYLES',
|
|
144
|
+
'is_soft_delete_enabled',
|
|
145
|
+
'as_bool',
|
|
146
|
+
'get_or_404',
|
|
147
|
+
'admin',
|
|
148
|
+
'Admin',
|
|
149
|
+
'add_admin_site',
|
|
150
|
+
'add_swagger_ui',
|
|
151
|
+
'SwaggerMount',
|
|
152
|
+
'ApiException',
|
|
153
|
+
'get_response',
|
|
154
|
+
'register_exception_handler',
|
|
155
|
+
'tr',
|
|
156
|
+
'get_lang',
|
|
157
|
+
'is_english',
|
|
158
|
+
'BaseModel',
|
|
159
|
+
'AbstractUser',
|
|
160
|
+
'ShardRule',
|
|
161
|
+
'ShardedModel',
|
|
162
|
+
'ShardError',
|
|
163
|
+
'expand',
|
|
164
|
+
'shards_of',
|
|
165
|
+
'is_shard',
|
|
166
|
+
'root_of',
|
|
167
|
+
'Validator',
|
|
168
|
+
'PaginationValidator',
|
|
169
|
+
'ModelSerializer',
|
|
170
|
+
'SerializerTypeError',
|
|
171
|
+
'Rule',
|
|
172
|
+
'StringRule',
|
|
173
|
+
'IntRule',
|
|
174
|
+
'FloatRule',
|
|
175
|
+
'BoolRule',
|
|
176
|
+
'DateRule',
|
|
177
|
+
'DateTimeRule',
|
|
178
|
+
'ListRule',
|
|
179
|
+
'DictRule',
|
|
180
|
+
'File',
|
|
181
|
+
'FileRule',
|
|
182
|
+
'ModelRule',
|
|
183
|
+
'Optional',
|
|
184
|
+
'ValidateError',
|
|
185
|
+
'get_project_root',
|
|
186
|
+
'set_project_root',
|
|
187
|
+
]
|
kksanic/__main__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""``python -m kksanic`` 的入口 —— 等价于 ``kksanic`` 控制台脚本。
|
|
2
|
+
|
|
3
|
+
注意:``python -m kksanic`` 会先执行 ``kksanic/__init__.py``,所以
|
|
4
|
+
sanic / tortoise 必须已经装好 —— 对脚手架来说这不构成限制(要让 ``main.py``
|
|
5
|
+
跑起来本来就得先装它们)。本模块自己只额外拉起 ``.cli``,那个子包死守标准库。
|
|
6
|
+
"""
|
|
7
|
+
from .cli import main
|
|
8
|
+
|
|
9
|
+
if __name__ == '__main__':
|
|
10
|
+
raise SystemExit(main())
|
kksanic/access_log.py
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
"""请求日志:一行一个请求(方法 / 路径 / 状态码 / 耗时 / 用户 / 来源 IP)。
|
|
2
|
+
|
|
3
|
+
为什么值得单独一个模块
|
|
4
|
+
----------------------
|
|
5
|
+
出问题时第一个要回答的问题是「**这个请求到底进来了没有、走的哪条路、慢在哪**」。
|
|
6
|
+
Sanic 自带的 access log 只打「收到一行」,没有状态码、没有耗时、更不知道是谁 ——
|
|
7
|
+
线上排查只能靠猜。这里补的就是这三样。
|
|
8
|
+
|
|
9
|
+
用法::
|
|
10
|
+
|
|
11
|
+
from kksanic.access_log import add_request_logging
|
|
12
|
+
|
|
13
|
+
add_request_logging(app)
|
|
14
|
+
|
|
15
|
+
输出(一行一个请求)::
|
|
16
|
+
|
|
17
|
+
GET /api/posts?page=1&page_size=10 200 12.4ms user=3 ip=127.0.0.1 rid=8f3a1c22
|
|
18
|
+
GET /api/me 401 0.3ms user=- ip=127.0.0.1 rid=bb02fe19
|
|
19
|
+
|
|
20
|
+
第三格是**真实 HTTP 状态码**:本框架的失败都用状态码表达(400 / 401 /
|
|
21
|
+
403 / 404),不再从 body 里额外抠一个业务码 —— 日志里每一次 401 都是
|
|
22
|
+
真的 401,肉眼和监控都能直接筛。
|
|
23
|
+
|
|
24
|
+
超过 ``SLOW_MS``、或 HTTP ``>= 500`` 的行会升到 ``WARNING``,日志级别
|
|
25
|
+
一眼就能筛出「慢」和「错」。
|
|
26
|
+
|
|
27
|
+
配置(项目根 ``settings.py``,全部**运行时**读取)::
|
|
28
|
+
|
|
29
|
+
REQUEST_LOG = {
|
|
30
|
+
'SLOW_MS': 500, # 超过这个耗时升 WARNING;0 = 不区分
|
|
31
|
+
'INCLUDE_QUERY': True, # 日志里带查询串(敏感值自动打码)
|
|
32
|
+
'SKIP_PATHS': ('/static', '/media'), # 免记录的路径前缀
|
|
33
|
+
'FILE': 'logs/access.log', # 落盘位置;None = 只走 logger
|
|
34
|
+
'FILE_MAX_BYTES': 10 * 1024 * 1024, # 单文件上限;0 = 不轮转
|
|
35
|
+
'FILE_BACKUP_COUNT': 5, # 保留 access.log.1 ~ .5
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
也可以直接传参覆盖(参数优先于 settings)::
|
|
39
|
+
|
|
40
|
+
add_request_logging(app, slow_ms=200, include_query=False)
|
|
41
|
+
add_request_logging(app, log_file='logs/access.log')
|
|
42
|
+
|
|
43
|
+
日志文件放在哪
|
|
44
|
+
--------------
|
|
45
|
+
``FILE`` 写相对路径时按**项目根**解析(``utils.get_project_root()``),
|
|
46
|
+
不是按进程 cwd —— 双击运行、计划任务、systemd 的 cwd 各不相同,按 cwd 解析
|
|
47
|
+
的结果是「日志跑到别的目录去了」,属于最难查的那类问题。
|
|
48
|
+
|
|
49
|
+
'logs/access.log' -> <项目根>/logs/access.log (目录自动创建)
|
|
50
|
+
'/var/log/app/api.log' -> 原样 (绝对路径不加工)
|
|
51
|
+
|
|
52
|
+
轮转:单文件超过 ``FILE_MAX_BYTES`` 改名成 ``access.log.1``,最多留
|
|
53
|
+
``FILE_BACKUP_COUNT`` 份,再老的删掉;总占用上限约
|
|
54
|
+
``FILE_MAX_BYTES * (FILE_BACKUP_COUNT + 1)``。
|
|
55
|
+
|
|
56
|
+
**多进程 worker 不要写同一个文件**:``RotatingFileHandler`` 不是进程安全的,
|
|
57
|
+
几个 worker 同时轮转会互相抢文件。多 worker 就让它走 stdout、交给 systemd /
|
|
58
|
+
docker 收;确实要落盘就用单 worker。
|
|
59
|
+
|
|
60
|
+
三个必须知道的点
|
|
61
|
+
----------------
|
|
62
|
+
1. **要注册在鉴权中间件之前**(先调 :func:`add_request_logging`,再调
|
|
63
|
+
``permission.add_permission_middleware``)。请求中间件按注册顺序执行,排在
|
|
64
|
+
后面时,被 401 拦下的请求拿不到开始时间,耗时那一栏是 ``-``(会照样记一行,
|
|
65
|
+
只是没有耗时)。
|
|
66
|
+
2. **不记请求体**。``Authorization`` 头、密码、上传内容都可能在里面,记进日志
|
|
67
|
+
等于二次泄漏。查询串里的 token / password 一类参数会被打成 ``***``。
|
|
68
|
+
3. **``OPTIONS`` 预检也记**。CORS 预检被 401 / 405 是常见的跨域故障,正好需要
|
|
69
|
+
这一行;嫌吵用 ``skip_methods=('OPTIONS',)`` 关掉。
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
import logging
|
|
73
|
+
import os
|
|
74
|
+
import time
|
|
75
|
+
from logging.handlers import RotatingFileHandler
|
|
76
|
+
|
|
77
|
+
from . import utils
|
|
78
|
+
from .i18n import tr
|
|
79
|
+
|
|
80
|
+
logger = logging.getLogger(__name__)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
DEFAULT_LOG_NAME = __name__
|
|
84
|
+
|
|
85
|
+
_SENSITIVE_KEYWORDS = (
|
|
86
|
+
'token',
|
|
87
|
+
'password',
|
|
88
|
+
'passwd',
|
|
89
|
+
'secret',
|
|
90
|
+
'auth',
|
|
91
|
+
'session',
|
|
92
|
+
'signature',
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
MAX_QUERY_LENGTH = 200
|
|
96
|
+
|
|
97
|
+
DEFAULT_SKIP_PATHS = utils.BUILTIN_LOG_SKIP_PATHS
|
|
98
|
+
|
|
99
|
+
DEFAULT_SLOW_MS = utils.BUILTIN_SLOW_MS
|
|
100
|
+
|
|
101
|
+
FILE_FORMAT = '%(asctime)s %(levelname)s %(message)s'
|
|
102
|
+
|
|
103
|
+
FILE_DATE_FORMAT = '%Y-%m-%d %H:%M:%S'
|
|
104
|
+
|
|
105
|
+
DEFAULT_LOG_MAX_BYTES = utils.BUILTIN_LOG_MAX_BYTES
|
|
106
|
+
DEFAULT_LOG_BACKUP_COUNT = utils.BUILTIN_LOG_BACKUP_COUNT
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _is_sensitive(name: str) -> bool:
|
|
110
|
+
"""参数名是否像凭证。"""
|
|
111
|
+
low = name.lower()
|
|
112
|
+
if low == 'key' or low.endswith('_key'):
|
|
113
|
+
return True
|
|
114
|
+
return any(word in low for word in _SENSITIVE_KEYWORDS)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _mask_query(query_string: str, limit: int = MAX_QUERY_LENGTH) -> str:
|
|
118
|
+
"""把查询串里的敏感值打成 ``***`` 并截断。
|
|
119
|
+
|
|
120
|
+
为什么不用 ``parse_qsl`` + ``urlencode``:那是「解析 → 重建」,两个来回
|
|
121
|
+
大约 10µs,而这条链路在**每个请求**上都要跑一次。按 ``&`` / ``=`` 切一刀
|
|
122
|
+
足够了 —— 这里的目的只是「别把 token 写进日志文件」,不是做一个通用解析器。
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
query_string: 原始的 ``request.query_string``(不含 ``?``)。
|
|
126
|
+
limit: 截断长度;超长时补 ``...``。
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
str: 打码后的查询串;空查询串返回空串。
|
|
130
|
+
"""
|
|
131
|
+
if not query_string:
|
|
132
|
+
return ''
|
|
133
|
+
|
|
134
|
+
parts = []
|
|
135
|
+
for item in query_string.split('&'):
|
|
136
|
+
name, sep, _value = item.partition('=')
|
|
137
|
+
parts.append(f'{name}=***' if sep and _is_sensitive(name) else item)
|
|
138
|
+
|
|
139
|
+
text = '&'.join(parts)
|
|
140
|
+
if len(text) > limit:
|
|
141
|
+
return text[:limit] + '...'
|
|
142
|
+
return text
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _user_id(user):
|
|
146
|
+
"""从 ``request.ctx.user`` 里取主键(dict / UserView / 模型实例都认)。
|
|
147
|
+
|
|
148
|
+
刻意在本模块重写一份、不 import ``permission._user_attr``:``permission``
|
|
149
|
+
一导入就拉 ``python-jose``,而「只想加个请求日志」的项目不该被拖下水。
|
|
150
|
+
"""
|
|
151
|
+
if user is None:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
if isinstance(user, dict):
|
|
155
|
+
value = user.get('id')
|
|
156
|
+
if value is None:
|
|
157
|
+
value = user.get('pk')
|
|
158
|
+
else:
|
|
159
|
+
value = getattr(user, 'id', None)
|
|
160
|
+
if value is None:
|
|
161
|
+
value = getattr(user, 'pk', None)
|
|
162
|
+
|
|
163
|
+
return value
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _client_ip(request) -> str:
|
|
167
|
+
"""来源 IP。
|
|
168
|
+
|
|
169
|
+
优先 ``client_ip``(Sanic 23.6 起的新入口):它是「反代给的 remote_addr,
|
|
170
|
+
没有就退回 socket 对端 ip」。**不能直接用 ``remote_addr``** —— 那个属性只
|
|
171
|
+
反映 ``X-Forwarded-For``,本机 / 没配反代时永远是空串(实测踩过:日志里
|
|
172
|
+
``ip=`` 后面什么都没有)。
|
|
173
|
+
|
|
174
|
+
老版本 Sanic 没有 ``client_ip``,退回 ``remote_addr`` / ``ip``。
|
|
175
|
+
"""
|
|
176
|
+
address = getattr(request, 'client_ip', None)
|
|
177
|
+
if not address:
|
|
178
|
+
address = (
|
|
179
|
+
getattr(request, 'remote_addr', None)
|
|
180
|
+
or getattr(request, 'ip', '')
|
|
181
|
+
)
|
|
182
|
+
return str(address or '')
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _format_elapsed(started) -> str:
|
|
186
|
+
"""耗时字段:拿不到开始时间就是 ``-``(别写成 0.0ms,那是假的)。"""
|
|
187
|
+
if started is None:
|
|
188
|
+
return '-'
|
|
189
|
+
return f'{(time.perf_counter() - started) * 1000:.1f}ms'
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _request_id(request) -> str:
|
|
193
|
+
"""Sanic 给每个请求生成的 id,取前 8 位做 ``rid``(和 Sanic 自己的错误日志对得上)。"""
|
|
194
|
+
value = getattr(request, 'id', None)
|
|
195
|
+
if not value:
|
|
196
|
+
return ''
|
|
197
|
+
return str(value)[:8]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def add_request_logging(
|
|
201
|
+
app,
|
|
202
|
+
log=None,
|
|
203
|
+
log_file=None,
|
|
204
|
+
log_max_bytes=None,
|
|
205
|
+
log_backup_count=None,
|
|
206
|
+
level='INFO',
|
|
207
|
+
slow_level='WARNING',
|
|
208
|
+
slow_ms=None,
|
|
209
|
+
skip_paths=None,
|
|
210
|
+
skip_methods=(),
|
|
211
|
+
include_query=None,
|
|
212
|
+
include_user=True,
|
|
213
|
+
include_ip=True,
|
|
214
|
+
include_request_id=False,
|
|
215
|
+
):
|
|
216
|
+
"""给整站挂上「一行一个请求」的访问日志。
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
app: Sanic 实例。
|
|
220
|
+
log: 自定义 ``logging.Logger``;不传用 ``kksanic.access_log`` 模块的
|
|
221
|
+
logger(跟随项目根 logger 的配置,不需要额外 basicConfig)。
|
|
222
|
+
log_file: 日志文件路径;``None`` 时读 ``settings.REQUEST_LOG['FILE']``,
|
|
223
|
+
都没有就**不落盘**(只交给 ``log``,去哪由项目的 logging 配置决定)。
|
|
224
|
+
相对路径按**项目根**解析、目录自动创建,详见模块文档「日志文件放在哪」。
|
|
225
|
+
log_max_bytes: 单文件上限(字节);``None`` 时读
|
|
226
|
+
``settings.REQUEST_LOG['FILE_MAX_BYTES']``,默认 10 MB。
|
|
227
|
+
``0`` 表示不轮转(一个文件一直往下写)。
|
|
228
|
+
log_backup_count: 保留几份历史文件(``access.log.1`` ~ ``.N``);
|
|
229
|
+
``None`` 时读 ``settings.REQUEST_LOG['FILE_BACKUP_COUNT']``,
|
|
230
|
+
默认 5。``0`` 表示不保留(轮转时直接覆盖旧文件)。
|
|
231
|
+
level: 正常请求的日志级别,默认 ``'INFO'``。可写字符串或
|
|
232
|
+
``logging.INFO`` 这类整数。
|
|
233
|
+
slow_level: 慢请求 / 5xx 的级别,默认 ``'WARNING'``。
|
|
234
|
+
slow_ms: 超过这个耗时(毫秒)升到 ``slow_level``;``None`` 时读
|
|
235
|
+
``settings.REQUEST_LOG['SLOW_MS']``,两者都没有则 ``500``;
|
|
236
|
+
``0`` 表示不区分快慢。
|
|
237
|
+
skip_paths: 不记录的**路径前缀**元组;``None`` 时读
|
|
238
|
+
``settings.REQUEST_LOG['SKIP_PATHS']``,都没有则
|
|
239
|
+
``('/static', '/media')``。判断口径与鉴权中间件一致:等于前缀,或
|
|
240
|
+
「前缀 + / 开头」,所以 ``'/static'`` 不会误跳过 ``/staticfoo``。
|
|
241
|
+
skip_methods: 直接不记录的方法,默认不跳(``OPTIONS`` 预检也记 ——
|
|
242
|
+
跨域故障往往就出在预检上)。
|
|
243
|
+
include_query: 是否把查询串写进日志;``None`` 时读
|
|
244
|
+
``settings.REQUEST_LOG['INCLUDE_QUERY']``,都没有则 ``True``。
|
|
245
|
+
include_user: 是否记录 ``user=<主键>``,默认 ``True``。
|
|
246
|
+
include_ip: 是否记录 ``ip=``,默认 ``True``。
|
|
247
|
+
include_request_id: 是否记录 ``rid=``(Sanic 的请求 id 前 8 位),
|
|
248
|
+
默认 ``False``。打开它每请求要多花 ~8µs —— ``request.id`` 是
|
|
249
|
+
**首次访问才生成**的 uuid,而 Sanic 自己不生成它(除非你访问)。
|
|
250
|
+
要和 Sanic 自己的报错日志对时间线时再开。
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
tuple: ``(请求中间件, 响应中间件)``。已经注册进 ``app``,返回它们只为
|
|
254
|
+
方便单测直接调用。
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
TypeError: ``level`` / ``slow_level`` 不是合法的日志级别。
|
|
258
|
+
|
|
259
|
+
Note:
|
|
260
|
+
重复调用是幂等的 —— 同一个 ``app`` 上第二次调用直接返回已注册的那一对
|
|
261
|
+
中间件,不会再挂一层(挂两层的结果是每个请求打两行)。
|
|
262
|
+
|
|
263
|
+
Note:
|
|
264
|
+
**请在鉴权中间件之前调用**。请求中间件按注册顺序跑,排在
|
|
265
|
+
``add_permission_middleware`` 之后时,被它 401 挡掉的请求拿不到开始
|
|
266
|
+
时间,耗时栏是 ``-``(行数照记,状态码也对)。
|
|
267
|
+
|
|
268
|
+
Note:
|
|
269
|
+
配了 ``log_file`` 是**加**一个输出端,不是替换 ``log``:终端里照样有,
|
|
270
|
+
文件里也多一份。只想留文件,就自己传一个 ``propagate = False`` 的
|
|
271
|
+
logger 进来。同一个文件重复调用只会挂一个 handler,不会写两遍。
|
|
272
|
+
|
|
273
|
+
Note:
|
|
274
|
+
落盘是**同步写**(每请求一次 ``write`` + ``flush``)。单 worker 每秒
|
|
275
|
+
几千请求没问题,再高就该让日志走 stdout 交给外面的收集器。
|
|
276
|
+
"""
|
|
277
|
+
existing = getattr(app.ctx, '_kk_access_log_middlewares', None)
|
|
278
|
+
if existing is not None:
|
|
279
|
+
return existing
|
|
280
|
+
|
|
281
|
+
number_level = _resolve_level(level, 'level')
|
|
282
|
+
slow_number_level = _resolve_level(slow_level, 'slow_level')
|
|
283
|
+
|
|
284
|
+
config = utils.get_request_log_settings()
|
|
285
|
+
|
|
286
|
+
if slow_ms is None:
|
|
287
|
+
slow_ms = config['SLOW_MS']
|
|
288
|
+
if skip_paths is None:
|
|
289
|
+
skip_paths = config['SKIP_PATHS']
|
|
290
|
+
if include_query is None:
|
|
291
|
+
include_query = config['INCLUDE_QUERY']
|
|
292
|
+
if log_file is None:
|
|
293
|
+
log_file = config['FILE']
|
|
294
|
+
if log_max_bytes is None:
|
|
295
|
+
log_max_bytes = config['FILE_MAX_BYTES']
|
|
296
|
+
if log_backup_count is None:
|
|
297
|
+
log_backup_count = config['FILE_BACKUP_COUNT']
|
|
298
|
+
|
|
299
|
+
prefixes = tuple(
|
|
300
|
+
str(item).rstrip('/') or '/' for item in (skip_paths or ())
|
|
301
|
+
)
|
|
302
|
+
skipped = tuple(str(item).upper() for item in (skip_methods or ()))
|
|
303
|
+
target = log if log is not None else logging.getLogger(DEFAULT_LOG_NAME)
|
|
304
|
+
threshold = max(0.0, float(slow_ms)) / 1000.0
|
|
305
|
+
|
|
306
|
+
if log_file:
|
|
307
|
+
_attach_file_handler(target, log_file, log_max_bytes, log_backup_count)
|
|
308
|
+
if number_level == logging.INFO and target.level == logging.NOTSET:
|
|
309
|
+
if target.getEffectiveLevel() > number_level:
|
|
310
|
+
target.setLevel(number_level)
|
|
311
|
+
|
|
312
|
+
@app.middleware('request')
|
|
313
|
+
async def _kk_access_log_start(request):
|
|
314
|
+
if getattr(request.ctx, '_kk_started', None) is None:
|
|
315
|
+
request.ctx._kk_started = time.perf_counter()
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
@app.middleware('response')
|
|
319
|
+
async def _kk_access_log_write(request, response):
|
|
320
|
+
try:
|
|
321
|
+
_write_line(
|
|
322
|
+
request,
|
|
323
|
+
response,
|
|
324
|
+
log=target,
|
|
325
|
+
level=number_level,
|
|
326
|
+
slow_level=slow_number_level,
|
|
327
|
+
threshold=threshold,
|
|
328
|
+
prefixes=prefixes,
|
|
329
|
+
skipped=skipped,
|
|
330
|
+
include_query=bool(include_query),
|
|
331
|
+
include_user=bool(include_user),
|
|
332
|
+
include_ip=bool(include_ip),
|
|
333
|
+
include_request_id=bool(include_request_id),
|
|
334
|
+
)
|
|
335
|
+
except Exception:
|
|
336
|
+
logger.warning(tr('写请求日志失败'), exc_info=True)
|
|
337
|
+
return response
|
|
338
|
+
|
|
339
|
+
pair = (_kk_access_log_start, _kk_access_log_write)
|
|
340
|
+
app.ctx._kk_access_log_middlewares = pair
|
|
341
|
+
return pair
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _resolve_log_path(path: str) -> str:
|
|
345
|
+
"""把配置里的路径变成绝对路径:相对路径按**项目根**解析。
|
|
346
|
+
|
|
347
|
+
Raises:
|
|
348
|
+
RuntimeError: 项目根确定不了(既没有 ``KKSANIC_PROJECT_ROOT``,也推断
|
|
349
|
+
不出来),而路径又是相对的。这时候猜一个 cwd 出来只会让日志落到
|
|
350
|
+
意料之外的目录,不如直接在启动期报错。
|
|
351
|
+
"""
|
|
352
|
+
expanded = os.path.expanduser(str(path).strip())
|
|
353
|
+
if os.path.isabs(expanded):
|
|
354
|
+
return os.path.abspath(expanded)
|
|
355
|
+
|
|
356
|
+
root = utils.get_project_root()
|
|
357
|
+
if root is None:
|
|
358
|
+
raise RuntimeError(
|
|
359
|
+
tr('请求日志用相对路径 {path!r} 时需要项目根目录,但没能确定(项目里没有 settings.py?);请改成绝对路径,或设置环境变量 {PROJECT_ROOT_ENV}', path=path, PROJECT_ROOT_ENV=utils.PROJECT_ROOT_ENV)
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
return os.path.abspath(os.path.join(str(root), expanded))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _attach_file_handler(log, path, max_bytes, backup_count):
|
|
366
|
+
"""给 logger 挂一个写文件的 handler;同一个文件只挂一次。
|
|
367
|
+
|
|
368
|
+
用 ``delay=True``:文件在第一次真正写的时候才打开。启动时先建好目录
|
|
369
|
+
(否则第一请求才炸,报错还落在请求里),但不创建一个空文件 —— 空文件
|
|
370
|
+
和「日志没写成功」在排查时很难区分。
|
|
371
|
+
|
|
372
|
+
Returns:
|
|
373
|
+
logging.Handler: 复用或新建的 handler。
|
|
374
|
+
"""
|
|
375
|
+
absolute = _resolve_log_path(path)
|
|
376
|
+
|
|
377
|
+
for handler in log.handlers:
|
|
378
|
+
if getattr(handler, 'baseFilename', None) == absolute:
|
|
379
|
+
return handler
|
|
380
|
+
|
|
381
|
+
directory = os.path.dirname(absolute)
|
|
382
|
+
if directory:
|
|
383
|
+
os.makedirs(directory, exist_ok=True)
|
|
384
|
+
|
|
385
|
+
if max_bytes and int(max_bytes) > 0:
|
|
386
|
+
handler = RotatingFileHandler(
|
|
387
|
+
absolute,
|
|
388
|
+
maxBytes=int(max_bytes),
|
|
389
|
+
backupCount=int(backup_count),
|
|
390
|
+
encoding='utf-8',
|
|
391
|
+
delay=True,
|
|
392
|
+
)
|
|
393
|
+
else:
|
|
394
|
+
handler = logging.FileHandler(absolute, encoding='utf-8', delay=True)
|
|
395
|
+
|
|
396
|
+
handler.setFormatter(logging.Formatter(FILE_FORMAT, FILE_DATE_FORMAT))
|
|
397
|
+
log.addHandler(handler)
|
|
398
|
+
return handler
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _resolve_level(value, name: str) -> int:
|
|
402
|
+
"""把 ``'INFO'`` / ``20`` 统一成 int 级别;认不出就报错。
|
|
403
|
+
|
|
404
|
+
不静默回落:写错级别(``'INFOO'``)时 ``logging`` 收到的是不合法的字符串,
|
|
405
|
+
表现为「日志一行都没有」,比启动时报错难查得多。
|
|
406
|
+
"""
|
|
407
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
408
|
+
return value
|
|
409
|
+
|
|
410
|
+
if isinstance(value, str):
|
|
411
|
+
number = logging.getLevelName(value.strip().upper())
|
|
412
|
+
if isinstance(number, int):
|
|
413
|
+
return number
|
|
414
|
+
|
|
415
|
+
raise TypeError(
|
|
416
|
+
tr('add_request_logging({name}=...) 需要日志级别("INFO" / logging.INFO),收到 {value!r}', name=name, value=value)
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _write_line(
|
|
421
|
+
request,
|
|
422
|
+
response,
|
|
423
|
+
log,
|
|
424
|
+
level,
|
|
425
|
+
slow_level,
|
|
426
|
+
threshold,
|
|
427
|
+
prefixes,
|
|
428
|
+
skipped,
|
|
429
|
+
include_query,
|
|
430
|
+
include_user,
|
|
431
|
+
include_ip,
|
|
432
|
+
include_request_id,
|
|
433
|
+
) -> None:
|
|
434
|
+
"""拼一行并写出去;被跳过的请求在这里就返回,连字符串都不拼。"""
|
|
435
|
+
method = request.method or ''
|
|
436
|
+
|
|
437
|
+
if method in skipped:
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
path = request.path or ''
|
|
441
|
+
for prefix in prefixes:
|
|
442
|
+
if prefix == '/':
|
|
443
|
+
return
|
|
444
|
+
if path == prefix or path.startswith(prefix + '/'):
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
started = getattr(request.ctx, '_kk_started', None)
|
|
448
|
+
elapsed_text = _format_elapsed(started)
|
|
449
|
+
elapsed = 0.0 if started is None else time.perf_counter() - started
|
|
450
|
+
|
|
451
|
+
status = getattr(response, 'status', 0) or 0
|
|
452
|
+
is_slow = bool(threshold) and elapsed >= threshold
|
|
453
|
+
is_error = status >= 500
|
|
454
|
+
number = slow_level if (is_slow or is_error) else level
|
|
455
|
+
|
|
456
|
+
if not log.isEnabledFor(number):
|
|
457
|
+
return
|
|
458
|
+
|
|
459
|
+
target = path
|
|
460
|
+
if include_query:
|
|
461
|
+
query = _mask_query(request.query_string or '')
|
|
462
|
+
if query:
|
|
463
|
+
target = f'{path}?{query}'
|
|
464
|
+
|
|
465
|
+
parts = [method, target, str(status), elapsed_text]
|
|
466
|
+
|
|
467
|
+
if include_user:
|
|
468
|
+
user_id = _user_id(getattr(request.ctx, 'user', None))
|
|
469
|
+
parts.append(f'user={user_id}' if user_id is not None else 'user=-')
|
|
470
|
+
|
|
471
|
+
if include_ip:
|
|
472
|
+
parts.append(f'ip={_client_ip(request)}')
|
|
473
|
+
|
|
474
|
+
if include_request_id:
|
|
475
|
+
rid = _request_id(request)
|
|
476
|
+
if rid:
|
|
477
|
+
parts.append(f'rid={rid}')
|
|
478
|
+
|
|
479
|
+
if is_slow:
|
|
480
|
+
parts.append(f'slow>{int(threshold * 1000)}ms')
|
|
481
|
+
|
|
482
|
+
log.log(number, ' '.join(parts))
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
__all__ = [
|
|
486
|
+
'add_request_logging',
|
|
487
|
+
'DEFAULT_SLOW_MS',
|
|
488
|
+
'DEFAULT_SKIP_PATHS',
|
|
489
|
+
'DEFAULT_LOG_MAX_BYTES',
|
|
490
|
+
'DEFAULT_LOG_BACKUP_COUNT',
|
|
491
|
+
'FILE_FORMAT',
|
|
492
|
+
'FILE_DATE_FORMAT',
|
|
493
|
+
]
|