前阵子把一个跑了三年的内部管理系统翻出来做安全复查,翻到数据访问层的时候血压有点上来。满屏都是这种写法:
def get_user(name):
sql = f"SELECT * FROM users WHERE name = '{name}'"
return db.execute(sql)
这种代码的问题不用展开说。当时想着要么全部改成参数化查询,要么找个更省事的办法。正好 Python 3.14 已经把 t-strings 落地了,就拿它把这批拼接点统一收拢了一遍,顺手也把 HTML 渲染和日志打码一起处理了。
这篇把整个改造过程拆开讲,重点不是介绍语法(语法真的一行就讲完了),而是 t-strings 到底给了你什么可以操作的中间结构,以及拿这个结构能做出什么在 f-string 时代做不了的事。
一、f-string 缺的到底是什么
f-string 的问题从来不是格式化能力,它的问题在于拼接的那一刻,结果就已经确定了。
name = "'; DROP TABLE users; --"
msg = f"SELECT * FROM users WHERE name = '{name}'"
# msg 已经是一个完整的字符串了,你在后面无论怎么处理都晚了
你没法在拼接发生之前插手,没法对某个插值做单独的转义,没法知道哪个片段是「我写的模板」、哪个片段是「外部传进来的数据」——因为拼完之后它们长得一模一样,全是一串字符。
Python 标准库里其实有过一个类似的解法:string.Template,用 $name 占位,配 substitute() 和 safe_substitute()。但它有两个死穴,一是语法丑($ 和 {} 混着来),二是占位符必须是字符串,你没法在里面写表达式。所以实际项目中用它的人很少。
t-strings 换了个思路。它不返回字符串,它返回一个结构。
二、t-string 的真实面目
语法上就是给字符串字面量加一个小写 t:
from string.templatelib import Template, Interpolation
name = "张三"
tpl = t"你好,{name}"
print(type(tpl))
# <class 'string.templatelib.Template'>
print(repr(tpl.strings))
# ('你好,', '')
print(repr(tpl.interpolations))
# (Interpolation('张三', 'name', None, ''),)
三件事一下就清楚了:
tpl不是str,它是Template实例。isinstance(tpl, str)是False,这一点后面会专门讲它带来的影响。strings是一个元组,装着模板里所有的字面量片段。注意它比插值数量多一项,最后那个空串是尾巴。interpolations是Interpolation的元组,每一项有四个属性:value(表达式的值)、expression(表达式的源码文本)、conversion(!r/!s/!a或None)、format_spec(格式说明符字符串)。
除了分别访问这两个属性,Template 本身可迭代,迭代会交替吐出 str 和 Interpolation。写处理器的时候用迭代方式更省事,不用手动对齐下标:
for item in tpl:
if isinstance(item, str):
print("字面量:", item)
else:
print("插值:", item.value, "来自表达式", item.expression)
输出:
字面量: 你好,
插值: 张三 来自表达式 name
这个 expression 属性是很多人第一次看会忽略的东西,但它在日志脱敏那个场景里非常好用,后面细说。
三、案例一:SQL 参数化构造器
目标很明确:写一个函数,接收 t-string,吐出(带占位符的 SQL, 参数列表)二元组。这样一来,用户传进来的值永远走数据库驱动的绑定通道,不参与 SQL 文本拼接,注入这条路直接堵死。
from string.templatelib import Template
class SQLBuildError(Exception):
pass
def to_sql(template: Template) -> tuple[str, list]:
if not isinstance(template, Template):
raise SQLBuildError(
"to_sql() 只接受 t-string,收到的是 %s。"
"请把 f"..." 改成 t"..."" % type(template).__name__
)
sql_parts = []
params = []
for item in template:
if isinstance(item, str):
sql_parts.append(item)
else:
if item.conversion is not None or item.format_spec:
raise SQLBuildError(
"SQL 参数不支持 !r / :格式 这类修饰:{"
+ (item.expression or '?') + "}"
)
sql_parts.append('%s')
params.append(item.value)
return ''.join(sql_parts), params
用起来是这样:
user_input = "'; DROP TABLE users; --"
sql, args = to_sql(t"SELECT * FROM users WHERE name = {user_input}")
print(sql)
print(args)
SELECT * FROM users WHERE name = %s
["'; DROP TABLE users; --"]
那串恶意输入原封不动地躺在了参数列表里,等着驱动去做绑定。SQL 文本里只有一个 %s,没有任何用户数据。这就是整个方案的核心。
为什么要对 conversion 和 format_spec 报错
这是我自己踩了才知道要加的一段。最开始版本里直接取了 item.value,没管另外两个属性。结果有人写了这种代码:
age = 25
sql, args = to_sql(t"SELECT * FROM t WHERE age = {age:03d}")
参数变成 25,而不是 "025"。format_spec 被悄无声息地丢掉了。在日志或者展示场景里这可能只是显示不对,但在 SQL 场景里它是一个静默的语义偏差——用户以为自己在格式化,实际没有,而且没有任何提示。
所以现在的策略是:SQL 场景下只要出现 conversion 或 format_spec,直接抛异常。参数就是参数,不该被格式化。这条规则看着严格,但它把一类很难排查的 bug 挡在了开发阶段。
动态拼接的坑
t-string 只能处理字面量模板。这一点必须说清楚:
column = "name"
condition = t"{column} = 'x'"
# 上面这一行本身是合法的,但 column 是一个普通字符串,
# condition 的 strings 是 ('', ' = 'x''),插值里是字符串 "name"。
# 如果你在 to_sql 里把它当参数处理,会得到:
# ("%s = 'x'", ["name"])
# 这显然不是你想要的结果。
表名、列名这类结构性的东西不能走参数绑定通道,数据库驱动只允许绑定值,不允许绑定标识符。这种需求只能靠白名单校验:
ALLOWED_COLUMNS = {"name", "email", "created_at"}
def safe_column(name: str) -> str:
if name not in ALLOWED_COLUMNS:
raise SQLBuildError(f"不允许的列名: {name!r}")
return name
然后调用的时候显式调它:to_sql(t"SELECT * FROM users ORDER BY {safe_column(order_by)}")——不行,这样还是会被当成参数。正确的做法是把列名拼进模板字面量之外的片段,或者用 SQL 组装器专门处理标识符。这块没有捷径,白名单是唯一的可靠办法。
四、案例二:HTML 自动转义渲染器
同样的结构,换一套处理规则,就得到了一套「默认安全」的 HTML 渲染器。思路是:字面量部分是我自己写的 HTML,信任;插值部分是外部数据,一律转义。
from html import escape as html_escape
def render_html(template: Template) -> str:
out = []
for item in template:
if isinstance(item, str):
out.append(item)
continue
value = item.value
if item.conversion == 'r':
value = repr(value)
elif item.conversion == 'a':
value = ascii(value)
elif item.conversion == 's':
value = str(value)
if item.format_spec:
value = format(value, item.format_spec)
out.append(html_escape(str(value)))
return ''.join(out)
试一下:
comment = '<script>alert(1)</script>'
html = render_html(t"<div class="comment">{comment}</div>")
print(html)
<div class="comment"><script>alert(1)</script></div>
外面的 <div> 保持原样,里面的 <script> 被转义成了 <script>。这个行为符合直觉,也符合安全默认。
如果你确实需要某段内容不转义(比如已经处理过的富文本),别想着放宽 render_html 的规则,写一个显式的标记类型更清楚:
class SafeHTML:
def __init__(self, html: str):
self.html = html
def render_mixed(template: Template) -> str:
out = []
for item in template:
if isinstance(item, str):
out.append(item)
elif isinstance(item.value, SafeHTML):
out.append(item.value.html)
else:
out.append(html_escape(str(item.value)))
return ''.join(out)
这样「不转义」这个决定就变成了类型系统里的一等公民,靠 SafeHTML 这个类型标记显式表达,而不是靠某个参数开关。审查代码的时候,搜一下 SafeHTML 就知道哪里有不转义的内容,心里有数。
五、案例三:日志脱敏
这个是三个场景里我自己最满意的一个,因为它用到了前面提到的 expression 属性。
场景是老生常谈:日志里不小心把手机号、身份证、token 打到文件里去了。常规的解法是打完日志再用正则往后扫描一遍,但正则有两难——写宽了误伤,写窄了漏网。
t-string 给了一个更直接的信息:插值来自哪个表达式,源码文本是什么。t"{user.token}" 的 expression 就是 "user.token",t"{get_id_card()}" 的 expression 就是 "get_id_card()"。拿这个字面量做关键词匹配,比拿运行时的值做正则准得多。
SENSITIVE_KEYWORDS = ("password", "passwd", "token", "secret",
"id_card", "idcard", "phone", "mobile")
def redact_log(template: Template) -> str:
out = []
for item in template:
if isinstance(item, str):
out.append(item)
continue
expr_text = (item.expression or "").lower()
if any(k in expr_text for k in SENSITIVE_KEYWORDS):
out.append("***")
else:
value = item.value
if item.conversion == 'r':
value = repr(value)
if item.format_spec:
value = format(value, item.format_spec)
out.append(str(value))
return ''.join(out)
调用:
uid = 1024
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx"
mobile = "13800001111"
print(redact_log(t"用户 {uid} 登录,token={token},手机号 {mobile}"))
用户 1024 登录,token=***,手机号 ***
好处在于它是在日志产生的那一刻脱敏的,而不是事后扫描。不需要担心格式变化或者日志内容有多层嵌套,源码里的变量名就是最可靠的信号。
当然这套东西也不是万能的。如果寄主把敏感值先赋给一个叫 x 的变量再打出来,expression 就是 "x",匹配不到。所以它只能作为一道防线,真正的规矩还得靠 code review 和静态检查。不过它挡掉的是最常见的那类疏忽——直接用原始变量名插进日志,这大概能覆盖七八成的情况。
六、五个我实际踩过的坑
坑 1:value 是原始值,转换和格式化得自己做
前面 SQL 案例里提过,这里再强调一次。Interpolation.value 保存的是表达式求值的结果,!r、!s、:03d 这些东西都还挂在 conversion 和 format_spec 上,没有应用。如果你写了个处理器只读 value,用户写的所有修饰符都会被静默忽略。
如果需要标准的 f-string 行为,直接对 Template 调 str() 就行,它的 __str__ 会按 f-string 的规则完整求值。但在安全场景里,你不该走这条路,因为那意味着放弃了转义的机会。
坑 2:Template 不是 str 的子类
旧代码里有很多地方会接收「字符串参数」,接进来之后 .strip()、in 判断、+ 拼接。当你把 t-string 递给这些函数时,不会报参数类型错误(Python 不强制),但运行时大概率会炸,或者更糟——悄悄走到一个你不期待的分支里。
我遇到的一个具体表现是:某处代码写的是 if keyword in text:,text 本来应该是 str,传进来一个 Template,in 操作走了 Template.__contains__——实际没有这个方法,于是走了迭代路径,最终判断结果完全不对。查了半天才发现问题所在。
所以处理器函数开头那个 isinstance(template, Template) 检查是有必要的,既能挡住误传,也能在报错信息里提示用户去改 f"..."。
坑 3:嵌套 t-string 会展开成普通对象
inner = t"world"
outer = t"hello {inner}"
这时 outer 的插值里,value 是一个 Template 对象,不是字符串。如果你的处理器里写了 str(item.value),它会被转成完整拼接的字符串,这通常没问题;但如果你想递归地对内层也做转义,就得自己识别类型并递归处理。render_html 里我没有做这个递归,因为实际业务中很少往里面塞 t-string,但如果是做通用模板引擎,这条得考虑到。
坑 4:表达式文本可能跟你想象的不一样
expression 保存的是源码原样。用户写 t"{ user.token }" 带空格,expression 就是 " user.token ";写 t"{a+b}",expression 就是 "a+b"。所以在做关键词匹配前记得 .lower() 并考虑空白,不要用全等判断。
另外,如果插值本身是嵌套的字符串,比如 t"{(lambda: 'token')()}",那 expression 里就是一串 lambda 源码。脱敏逻辑遇到这种只能认栽,它本来就不是为防绕过设计的。
坑 5:别把渲染结果再拼回 t-string
有人可能会想:先渲染一遍得到一个字符串,再把它塞到另一个 t-string 里继续处理。这会导致安全属性丢失——第一遍渲染完的字符串进了第二遍的插值通道,会被再转义一次,出现 &lt; 这种双重转义。真要分层处理,就在同一个处理器里分阶段做,不要在字符串层面来回过。
七、性能大概是个什么水平
担心性能的话可以先放一下。t-string 在字面量部分是在编译期就确定好的(strings 元组是常量),运行时主要开销是构建 Interpolation 对象和那个 Template 实例。
我拿一个简单的 t"a{x}b{y}c" 在本地跑了一百万次对比,t-string 和同结构的 f-string 在耗时上是同一个量级,差距落在几毫秒到几十毫秒之间,具体数字随机器波动不定。真要说有差距,那也只是构造对象的固定成本,在你后面要接数据库或网络调用的场景里,这点开销完全可以忽略。
真正会显著变慢的情况是在热循环里反复构建大型 Template。如果你的模板有几十个插值、每秒要渲染上万次,那可以考虑缓存渲染结果,或者干脆退回手工拼接。但一般的 Web 请求、脚本任务、日志输出,t-string 的这点成本完全不是问题。
八、收一下
t-strings 最有意思的地方,是它把「模板」和「数据」这两个在 f-string 时代被揉成一团的东西重新分开了。一旦分开,你就有地方可以插手——转义、参数化、脱敏、审计,都变得可能。
但它也不是银弹。它只给你一个结构,怎么用这个结构写处理器,还是得自己想。SQL 那种场景适合严苛的规则,连 !r 都不放过;HTML 场景适合默认转义加显式豁免;日志场景适合基于变量名的启发式匹配。三个场景的处理器逻辑完全不一样,只是共享同一个输入结构。
如果你手上也有老项目在做安全加固,建议先把数据访问层的拼接点整理出来,加一个 to_sql 这样的入口,让所有的 SQL 构造都从这里走。改造过程其实不复杂,麻烦的是让团队里的每个人都愿意改掉写 f-string 的肌肉记忆。

