前段时间 review 一段老代码,看到一个查询用户订单的函数长这样:
def get_orders(uid, status):
sql = f"SELECT * FROM orders WHERE uid = {uid} AND status = '{status}'"
return db.execute(sql)
uid 是 int 还好,status 是字符串,直接拼进单引号里。当时我在评论区写了一句”这玩意儿能注入”,作者回我”前端限制了选项的”。这话我听过不下十次,每次都想叹气。
但这次不聊怎么修,聊一个更根本的问题:f-string 从设计上就没打算给你拦截的机会。Python 3.14 引入的 t-strings 想解决的正是这件事。这篇文章会把它的结构掰开,然后用它写三个能直接放进项目的工具。
f-string 的问题不是”不安全”,是”没得管”
f-string 里能塞任意表达式,包括函数调用:
user_input = "{__import__('os').system('rm -rf /')}"
print(f"结果:{user_input}") # 安全,只是打印字符串
print(f"结果:{eval(user_input)}") # 不安全,但这是 eval 的锅
这个例子常被拿来吓唬人,其实有点冤枉 f-string。真正的麻烦在别处:一旦写下 {...},求值和拼接是原子的,中间没有任何钩子。你不能在拼接之前检查一下这个值是不是脏的,也不能决定”这个位置要当一个 SQL 参数,而不是一段字面量”。
所以每次处理用户输入,都得绕开 f-string,改用参数化查询:
db.execute("SELECT * FROM orders WHERE uid = ? AND status = ?", (uid, status))
参数化的能力一直都在,问题在于——没有强制,全靠自觉。哪天有人为了加一个”根据字段名排序”的功能,写了一行 f"ORDER BY {sort_field}",你又得去 review 一次。
t-string 换了个思路
Python 3.14 里可以这么写:
from string.templatelib import Template, Interpolation
name = "Alice"
template = t"SELECT * FROM users WHERE name = {name}"
print(type(template)) # <class 'string.templatelib.Template'>
语法上跟 f-string 一模一样,只是前缀从 f 变成 t。但结果完全不同:template 不是字符串,而是一个 Template 对象。表达式根本没有被转换成字符串,只是被”记录”下来了。
这意味着,处理权交给了你。你想把它拼成 SQL,就拼成 SQL;想 HTML 转义,就 HTML 转义;想直接拒绝,就抛异常。
这一点是核心,比”能写自定义字符串格式化”的说法准确得多。
Template 里面到底装了什么
一个 Template 对象有两个关键属性,还有一个很好用的迭代行为。
strings 和 interpolations
name = "Alice"
age = 30
t = t"Hi {name}, age {age + 1}"
t.strings # ('Hi ', ', age ', '')
t.interpolations # (Interpolation('Alice', 'name'), Interpolation(31, 'age + 1'))
t.values # ('Alice', 31)
注意几个细节。
第一,strings 的长度永远比 interpolations 多一个,即使末尾没有内容,也会有一个空字符串占位。个数对齐之后,处理起来不用额外的边界判断。
第二,每个 Interpolation 里有四个属性:
value:表达式求值后的实际对象,这里是"Alice"和31。原始类型没有被转成字符串,这一点非常重要。expression:源代码文本,这里是"name"和"age + 1"。做调试和日志的时候很有用。conversion:转换字符,!r是'r',没写就是None。format_spec:格式说明符,比如:>10就是">10",没写是空字符串。
迭代行为
处理 template 时,最顺手的写法是直接迭代它:
for item in t:
if isinstance(item, str):
... # 静态文本
elif isinstance(item, Interpolation):
... # 插值槽
迭代会按顺序把静态片段和插值槽交替吐出来,顺序和你在源码里写的完全一致。写转换函数时用这个模式,比手动按索引对齐 strings 和 interpolations 要清爽很多。
案例一:防注入 SQL 构造器
先立个目标:希望调用方能写成这样,同时接口是安全的。
stmt, params = sql(t"SELECT * FROM users WHERE id = {user_id} AND status = {status}")
db.execute(stmt, params)
字段名这种不能参数化的东西,要显式包一层。
先写标识符校验
import re
_IDENT_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
class Identifier:
__slots__ = ("name",)
def __init__(self, name: str):
if not _IDENT_RE.match(name):
raise ValueError(f"非法的 SQL 标识符: {name!r}")
self.name = name
def __repr__(self):
return f"Identifier({self.name!r})"
关键点是白名单式的正则。不要用黑名单去过滤分号、空格、注释符,那种做法总有漏网的写法。只允许字母数字下划线,其他一律拒绝,简单有效。
主转换函数
from string.templatelib import Template, Interpolation
def sql(template: Template) -> tuple[str, list]:
if not isinstance(template, Template):
raise TypeError("sql() 只接受 t-string")
parts = []
params = []
for item in template:
if isinstance(item, str):
parts.append(item)
continue
if not isinstance(item, Interpolation):
raise TypeError(f"意外的类型: {type(item)}")
value = item.value
if isinstance(value, Identifier):
parts.append(value.name)
elif isinstance(value, (list, tuple, set, frozenset)):
seq = list(value)
if not seq:
parts.append("(NULL)")
else:
parts.append("(" + ", ".join("?" * len(seq)) + ")")
params.extend(seq)
elif value is None:
parts.append("NULL")
else:
parts.append("?")
params.append(value)
return "".join(parts), params
几处值得说明的设计。
标识符和值走两条路。 参数化查询的原理决定了表名、列名、排序字段这些结构性的东西没法用占位符,只能拼进 SQL 文本。所以这里用 Identifier 显式包装,进来之前必须先过正则。普通值一律走 ? 占位符。
自动展开 IN 子句。 WHERE id IN {ids} 这种写法很常见,手写占位符又容易数错个数,交给构造器处理省心。空列表处理成 (NULL),保证 IN (NULL) 永远不匹配任何行,语义上符合直觉。
None 直接写成 SQL 的 NULL。 不能参数化成 ?,因为 id = NULL 永远为假,得写 IS NULL。但这里只做到了字面量替换,如果调用方写 WHERE status = {None},生成的语句还是 WHERE status = NULL,语义上是错的。
这个问题没有通用解,除非你解析整个 SQL 语法树。实际项目里我的做法是:检测到 None 时给出警告,让调用方改成显式的 IS NULL 语句。宁可在日志里多一条提醒,也不要在生产上悄悄返回空结果。
用起来是什么感觉
user_id = 42
status = "paid'; DROP TABLE users; --"
fields = Identifier("created_at")
order_ids = [101, 102, 103]
stmt, params = sql(t"""
SELECT id, amount, {fields}
FROM orders
WHERE user_id = {user_id}
AND status = {status}
AND id IN {order_ids}
ORDER BY {fields} DESC
""")
print(stmt)
print(params)
如果 status 带了注入内容,它会被塞到参数列表里原封不动传给驱动,数据库会把整段字符串当作一个普通值来匹配,什么也不会发生。这就是参数化的价值。
如果调用方忘了包 Identifier,直接写 {fields},那也会被当作参数处理,SQL 文本里只有一个问号,驱动在 ORDER BY ? 的位置会报语法错误。这种失败方式是好的失败方式——出错比悄悄出结果安全。
案例二:HTML 安全渲染
同样的思路套到 HTML 上。很多人对模板引擎的 {{ }} 默认转义已经习以为常了,但手写拼接 HTML 的代码还是到处都是,尤其是在生成邮件内容和导出报告的时候。
目标 API:
html(t"<p>你好,{nickname}</p>")
其中 nickname 无论是什么内容,输出时都会被正确转义。
转义函数
def escape(text) -> str:
s = str(text)
# 顺序不能变,& 必须第一个处理
return (
s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace('"', """)
.replace("'", "'")
)
五个字符里的顺序问题:如果先替换 <,会把 < 里的 & 也一起替换掉,最后变成 &lt;,页面上用户就会看到一堆 < 的字面量。& 永远是第一步。
已信任内容怎么办
全转义有个副作用:你拼出来的那些标签本身也会被转义掉。比如:
html(t"<p>{user_input}</p>")
静态部分 "<p>" 和 "</p>" 会原样进入结果,没问题。但如果结果是嵌进另一层模板里的,第二层就会把它们转义掉。
解决办法是引入一个”已信任”的标记类型,跟 Jinja2 的 Markup 一个思路:
class SafeString(str):
"""标记为已经过转义或本身可信的 HTML 片段。"""
__slots__ = ()
def __html__(self):
return str(self)
def html(template: Template) -> SafeString:
parts = []
for item in template:
if isinstance(item, str):
parts.append(item)
else:
v = item.value
if isinstance(v, SafeString):
parts.append(str(v))
else:
parts.append(escape(v))
return SafeString("".join(parts))
这样 html() 的输出可以安全地嵌套进更外层的模板,不会出现双重转义。判断逻辑只有一条:是 SafeString 就放行,不是就转义。没有第三种情况,也就没有绕过转义的意外路径。
一个常见的顾虑
有人会问:SafeString 是不是继承了 str,那它不就等于还没转义吗?
答案是不是。关键不在类型,在于谁负责转义。所有进入 html() 的普通值都会被转义,出来的结果是一个 SafeString,意思是”我已经转义过了”。它本质上是一个承诺,不是一种特权。要是谁手动构造一个 SafeString(user_input),那确实会绕过检查,但这时候写代码的人是在明确说”我确认这段内容安全”,责任归属很清楚。
安全设计里,”能否绕过”和”绕过得写一行明显的代码”是两回事。后者的价值在于,review 时 grep SafeString 就能把可疑的地方全捞出来。
案例三:日志脱敏
t-string 的另一个顺手场景是把敏感信息从日志里挤出去。
import re
_PATTERNS = [
(re.compile(r"1[3-9]d{9}"), lambda s: s[:3] + "****" + s[-4:]),
(re.compile(r"bd{17}[dXx]b"), lambda s: s[:6] + "********" + s[-4:]),
(re.compile(r"bd{16,19}b"), lambda s: "****" + s[-4:]),
]
def _mask_text(s: str) -> str:
for pattern, repl in _PATTERNS:
s = pattern.sub(lambda m: repl(m.group()), s)
return s
def safe_log(template: Template) -> str:
out = []
for item in template:
if isinstance(item, str):
out.append(_mask_text(item))
else:
v = item.value
# 只对字符串做脱敏,避免影响数字类型的格式化
if isinstance(v, str):
out.append(_mask_text(v))
else:
out.append(_apply_format(item))
return "".join(out)
这里引出一个躲不开的问题:插值上的格式化怎么处理。
如果调用方写了 t"金额 {amt:.2f}",直接 str(v) 会丢掉两位小数的格式化。正确做法是先应用 conversion,再应用 format_spec:
def _apply_format(item) -> str:
value = item.value
if item.conversion == "r":
value = repr(value)
elif item.conversion == "s":
value = str(value)
elif item.conversion == "a":
value = ascii(value)
if item.format_spec:
value = format(value, item.format_spec)
return value if isinstance(value, str) else str(value)
顺序和 f-string 的语义一致:先 conversion 后 format_spec。f"{x!r:>20}" 的含义是”先取 repr,再右对齐到 20 宽”,写反了结果就对不上。
另外,跟普通的 f-string 不同,Interpolation.__format__ 在 t-string 里不会被自动调用。你不处理 format_spec,它就是一个躺在对象里的字符串,谁也不管。这一点刚上手容易漏。
顺带提醒,脱敏规则应该先字符串化再匹配。value 可能是个 Decimal 或者自定义对象,直接喂给正则的 sub 会报类型错误。上面代码里只对 str 类型做脱敏,非字符串走格式化路径,避开这个问题。
几个会踩到的坑
1. Template 不是字符串
最常见的错误是把它当字符串用:
t = t"hello {name}"
t.upper() # AttributeError
需要字符串时,走转换函数,或者调用 t.format()。后者返回的是跟 f-string 一样的结果,但它不做任何转义,一律不做。真要用它,得先想清楚为什么需要它而不是自己写处理函数。
2. conversion 的值不是符号
item.conversion 拿到的是 'r'、's'、'a',或者 None。不是 '!r',也不是 repr。写判断的时候别写成字符串带感叹号。
3. 嵌套的 t-string 会原样保留
inner = t"a={a}"
outer = t"prefix {inner} suffix"
outer 里那个插值的 value 是另一个 Template 对象,不会自动展开。如果你的场景需要递归处理,得自己写递归,或者在处理前先判断 isinstance(v, Template) 再走一遍转换。不处理的话,日志里就会出现一串 <string.templatelib.Template object at 0x...>。
4. 别把处理函数写成万能转换器
有些实现会在一个函数里判断”这个值是数字就用数字路径,是字符串就用字符串路径,是 SQL 就拼 SQL”。这种做法短期省事,长期必然出问题——调用方根本不知道在什么条件下会走什么分支。
我的建议是一个函数只做一件事:sql() 就只生成 SQL,html() 就只生成 HTML,需要组合的场景,先转成对应的 SafeString 类型再嵌。类型明确,责任才明确。
兼容性怎么处理
t-strings 在 Python 3.14 正式落地,3.13 及以前都不认识 t"" 这个前缀,代码会直接语法报错。这不是运行时兼容问题,是解析阶段就挂。
如果项目暂时升不到 3.14,有两个方向。
一是用 string.Template 或者自研的轻量解析,但损失了拼接表达式的灵活性,t"age {age + 1}" 这种写法就得改成先把 age + 1 算出来再传变量名,用起来差很多。
二是各模板引擎自带的方案。Jinja2 的 Markup、Django 的 mark_safe、SQLAlchemy 的表达式构造器,这些工具在各自领域都很成熟,t-string 的价值在于它不绑定任何领域,一个机制能同时覆盖 SQL、HTML、日志、CLI 输出。如果你手上已经有三个模板引擎在处理同类问题,那 t-string 是值得等的一个统一入口。
还有一点容易忽略:如果项目里同时有 Python 3.12 和 3.14 的环境(比如本地开发用新版,CI 用旧版),含 t"" 的文件需要做版本隔离,或者在 CI 里明确跳过。用 pyproject.toml 里的 requires-python 声明清楚最低版本,比靠口头约定靠谱。
写完这几段代码之后
t-strings 最吸引我的地方,是它没打算变成一个更花哨的字符串格式化工具,而是老老实实承认了一件事:字符串插值本来就不该是语言层面的原子操作,中间应该有函数插手的位置。
我团队里有个习惯,凡是拼接外部输入的地方,一律走转换函数。以前是靠 code review 和自定义 lint 规则硬撑,现在终于有语言层的支持了。三个函数不算多,但省下来的调试时间、以及避免的那次线上事故,值这个价。
如果你的项目里有类似的拼接点,可以先把最简单的那个抽出来试一下。不用一步到位,先跑起来再说。

