Python t-string 模板字符串实战:自定义插值处理与 SQL/HTML 安全输出

2026-09-24 0 651

f-string 是 Python 3.6 以来最受欢迎的特性,没有之一。写 f"hello {name}" 比手动 "hello " + name 或者 "hello {}".format(name) 舒服太多,可读性和性能也都好。这几年用下来几乎没有人怀念老写法。

但用得越久,越会撞到同一件事:f-string 是即时求值并拼成 str。它对插值表达式的处理到此为止——值被算出来、被转成字符串、被拼在一起,中间那点结构信息全部丢掉了。绝大多数场景这样挺好,可有些场景偏偏需要保留那点结构。

举个例子。日志里写 f"user={uid} action={act} cost={ms}",输出是一整行字符串。想把它变成结构化日志,让日志系统按字段过滤,得再写一次 {"user": uid, "action": act, "cost": ms}。两处重复。SQL 里写 f"SELECT * FROM {table} WHERE id = {uid}",你得自己记得去参数化,忘了就是注入。HTML 里写 f"<div>{comment}</div>",你得记得对 comment 转义,忘了就是 XSS。

这些问题的根都在一处:f-string 把「模式」和「值」直接搅成了字符串,没有给中间层留任何位置。Python 3.14 加进来的 t-string(PEP 750)就是来补这个缺口的。

一、t-string 到底返回什么

语法上只差一个字母:f-string 用 f,t-string 用 t

name = "alice"
score = 42.5

f_string = f"player={name} score={score}"
t_string = t"player={name} score={score}"

print(type(f_string))   # <class 'str'>
print(type(t_string))   # <class 'string.templatelib.Template'>

差别就在这。t-string 求值得到的不是 str,是一个 Template 对象。它把插值过程拆成了几个可读的部分:

  • strings:静态文本片段组成的元组。长度总比插值多一个。
  • interpolations:插值对象组成的元组。
  • values:插值值的元组,等价于每个插值的 .value

拿上面那行 t_string 举例:

print(t_string.strings)
# ('player=', ' score=', '')

print(len(t_string.interpolations))   # 2

for interp in t_string.interpolations:
    print(repr(interp.expression), '=>', interp.value)

# 'name' => alice
# 'score' => 42.5

注意 strings 的最后一个元素是空字符串。这是有意为之的——它保证了「n 个插值对应 n+1 个静态片段」,这样按顺序拼接的时候不用做特殊的边界处理。

每个 Interpolation 对象上存的东西比想象中多:

  • value:表达式求值后的结果,原始对象,不经过任何字符串化。
  • expression:表达式的源码字符串。t"{user.name}" 里这个字段就是 "user.name"。这在做结构化输出的时候特别有用。
  • conversion:格式转换字符,比如 !r 会得到 'r',没写就是 None
  • format_spec:冒号后面的格式描述。t"{x:.2f}" 这里就是 ".2f"

普通 f-string 会把这些信息吃掉。f"{x:.2f}" 出来就是一个字符串 "3.14",没有任何办法知道它原来是浮点数、用的是什么格式、源表达式是什么。t-string 把这些全部保留了下来,交给处理者(也就是你)去决定怎么用。

二、把它变成字符串:str 与 format

不处理的话,Template 也能当字符串用:

t = t"total = {price:.2f} yuan"
print(str(t))        # total = 3.14 yuan
print(f"{t}")        # total = 3.14 yuan
print(format(t))     # total = 3.14 yuan

__str____format__ 的行为和 f-string 完全一致,包括 !r!s、格式描述符全都照常生效。这意味着 t-string 不会破坏那些只需要字符串的地方,可以直接传进去。

但要小心一个反直觉的点:t == "..." 永远为 FalseTemplate 不等于任何字符串,哪怕 str(t) 看起来一样。因为它不是 str 的子类。这点在设计 API 时要考虑到——如果一个函数的参数标注是 str,传 Template 进去不会立即报错,但后续比较、正则匹配、切片之类都会出问题。要用就显式 str() 一下,或者改成接受 Template | str

三、开始写第一个处理器:SQL 参数化

先从最有价值的一个场景开始。规则很简单:静态部分原样保留,插值部分全部变成占位符 ?,值收集到一个参数元组里。

from string.templatelib import Template, Interpolation

def to_sql(template: Template) -> tuple[str, tuple]:
    parts = []
    params = []

    for i, static in enumerate(template.strings):
        parts.append(static)
        if i < len(template.interpolations):
            parts.append("?")
            params.append(template.interpolations[i].value)

    return "".join(parts), tuple(params)

怎么用:

uid = 42
status = "active"
sql, args = to_sql(t"SELECT * FROM users WHERE id = {uid} AND status = {status}")

print(sql)     # SELECT * FROM users WHERE id = ? AND status = ?
print(args)    # (42, 'active')

cur.execute(sql, args)

关键点在于,这个 to_sql 函数是值无关的。不管 uid 里塞的是 42 还是 "1 OR 1=1 --",函数都会把它变成 ? 丢进参数元组,数据库驱动负责安全绑定。注入在这里不可能发生,因为整个流程里根本没有任何字符串拼接的机会。

这比用 sqlite3 那种 ? 或者 %s 的手写方式好在哪?好在目标是 不可能写错的。当 t"..." 出现在代码里,它就已经不是字符串了,必须过 to_sql 才能用。如果哪天有人不小心把它直接传给 cursor.executeTemplate 对象不是 str,驱动会立刻抛类型错误。这就是类型系统在帮忙兜底。

需要标识符插值怎么办

表名、列名不能参数化,只能直接拼,因为 SQL 占位符只对值有效。这是个实际项目中必须处理的问题,方法是对标识符做白名单校验:

import re

_IDENTIFIER_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,62}$")

def safe_ident(value: str) -> str:
    if not isinstance(value, str) or not _IDENTIFIER_RE.match(value):
        raise ValueError(f"非法标识符: {value!r}")
    return value

def to_sql_v2(template: Template) -> tuple[str, tuple]:
    from string.templatelib import convert

    parts = []
    params = []
    for i, static in enumerate(template.strings):
        parts.append(static)
        if i < len(template.interpolations):
            interp = template.interpolations[i]
            # 用 format_spec 里的特殊标记区分标识符
            if interp.format_spec == "sql:ident":
                parts.append(safe_ident(str(interp.value)))
            else:
                parts.append("?")
                params.append(interp.value)
    return "".join(parts), tuple(params)

调用方式就说 t"SELECT * FROM {table:sql:ident} WHERE id = {uid}"。表名走白名单,ID 走参数化。这里 format_spec 从「控制格式」变成了「控制插值语义」,虽然有点超出 PEP 750 的原始意图,但社区里已经在用类似的做法了。要是觉得太 hacky,也可以定义一个新的转换字符,比如把 !i 用作标识符标记,Interpolation.conversion 里会拿到 'i'

四、第二个处理器:HTML 自动转义

HTML 生成的问题和 SQL 一样:随手一写就有 XSS 漏洞。t-string 的思路是让转义成为默认行为,只有明确标记为安全的片段才原样输出。

from html import escape

class SafeHTML:
    """显式标记为不需要转义的内容。"""
    __slots__ = ("_html",)

    def __init__(self, html: str):
        self._html = html

    def __str__(self):
        return self._html

    def __html__(self):
        return self._html


def to_html(template: Template) -> SafeHTML:
    parts = []
    for i, static in enumerate(template.strings):
        parts.append(static)
        if i < len(template.interpolations):
            value = template.interpolations[i].value
            if isinstance(value, SafeHTML):
                parts.append(str(value))
            else:
                parts.append(escape(str(value), quote=True))
    return SafeHTML("".join(parts))

用法:

username = "<script>alert(1)</script>"
card = to_html(t"<div class='user'>{username}</div>")
print(card)
# <div class='user'>&lt;script&gt;alert(1)&lt;/script&gt;</div>

值里的尖括号被转义了。再看一个显式标记为安全的例子:

logo = SafeHTML("<img src='/logo.png' alt='logo'>")
header = to_html(t"<header>{logo}<h1>{title}</h1></header>")
print(header)
# <header><img src='/logo.png' alt='logo'><h1>...</h1></header>

logo 里的标签保留了下来。因为它被 SafeHTML 包装过,处理器明确知道这是「开发者自己写的可信内容」,跳过转义。

这个模式和 Jinja2 的 Markup、Django 的 mark_safe 是同一个思想。t-string 的好处在于它把这个思想集成到了语法级别——你没法「忘了转义」或者「忘了标记安全」,因为转义与否取决于值的类型,而不取决于你记得写 escape() 还是 mark_safe()

五、第三个处理器:结构化日志字段

这个最有意思。用插值表达式的源码字符串当字段名,可以把一行 t-string 直接变成「模板 + 字段」的两部分。

import re
import json

_FIELD_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_.]*")

def to_log(template: Template) -> tuple[str, dict]:
    parts = []
    fields = {}
    counter = 0

    for i, static in enumerate(template.strings):
        parts.append(static)
        if i < len(template.interpolations):
            interp = template.interpolations[i]
            expr = (interp.expression or "").strip()

            if _FIELD_RE.fullmatch(expr):
                key = expr.split(".")[-1]   # user.id -> id
            else:
                counter += 1
                key = f"arg{counter}"

            # 同名冲突就加后缀,避免丢字段
            base_key = key
            suffix = 1
            while key in fields:
                suffix += 1
                key = f"{base_key}_{suffix}"

            fields[key] = interp.value
            parts.append("{" + key + "}")

    return "".join(parts), fields

用法:

user = "alice"
order_id = 9981
amount = 199.00

msg, extra = to_log(t"order paid user={user} order={order_id} amount={amount}")

print(msg)    # order paid user={user} order={order_id} amount={amount}
print(extra)  # {'user': 'alice', 'order': 9981, 'amount': 199.0}

logger.info(msg, extra=extra)

这个输出可以直接喂给 logging 或者 structlog。日志系统拿格式化消息做展示,拿 extra 做过滤、聚合、报警。一行代码同时满足人类可读和机器可读两个需求。

字段名的推导有一点小细节。t"{user.id}" 里表达式是 "user.id",直接当 key 会带上点号,很多日志系统不喜欢。上面代码里取了最后一段,变成 "id"。如果表达式不是简单的名字(比如 t"{len(items)}"),fullmatch 匹配不上,就退化成 arg1 这样的编号,不丢数据只是不够美观。

六、组合多个处理器

有时候需要同时做几件事。比如生成一份「既安全又带结构化字段」的日志载荷,或者「插入数据库时需要参数化,同时也要保留人类可读的原始值」。可以把处理器串起来:

def composed(*handlers):
    def apply(template: Template):
        result = template
        for h in handlers:
            result = h(result)
        return result
    return apply

不过我不太推荐这种做法。每个处理器有自己的返回形态(一段字符串、一个二元组、一个标记对象),串起来之后类型会变得很混乱。更好的方式是为每个场景单独写一个处理器,把逻辑放在里面。好处是每个处理器的输入输出契约都明确,也就是那句老话——「宁可每个函数简单,不要让组合变得复杂」。

七、几个必须提前知道的事

版本和依赖。t-string 是 Python 3.14 正式引入的。如果你现在跑的是 3.12 或 3.13,用不了。3.14 现在是新版本,部署环境要提前评估。有些团队会在 CI 里跑多个 Python 版本,别忘了在 pyproject.toml 里把 requires-python 升上去。

t-string 不是 str 的子类。上面提过,但值得再说一遍。isinstance(t"...", str)False。任何接受 str 的 API,直接传 Template 进去都可能踩雷——json.dumps 会报错、re.match 会报错、.upper() 没有这个方法。用之前先明确知道对方需要什么类型。

表达式不是惰性的。t-string 在创建的那一刻就把插值表达式全求值了,和 f-string 一样。它改变的是「求值之后怎么处理」,不是「什么时候求值」。想做惰性求值,还是得用 lambda 或者字符串加 eval——两个都不推荐用到生产里。

不要在处理器里做副作用。处理器就是个函数,接收 Template 返回结果。在里面发 HTTP、写文件、改全局变量都能做,但一旦处理器有副作用,测试就不好写了。除非有一个特别明确的理由,否则处理器保持纯函数,副作用留给调用方。

性能。处理器比直接 f-string 慢——多了一层函数调用、多了一次循环、多了一次字符串拼接。在热路径上(一秒钟几十万次调用)要谨慎。但绝大多数场景,比如构造 SQL、生成 HTML 片段、组装日志,都不在这个量级上。先测一遍再决定要不要优化,不要为了理论上的性能损失提前放弃。

别把处理器的复杂性藏在深处。t-string 的卖点是「插值的处理方式一目了然」。如果一个处理器里有一堆分支、一堆正则、层层嵌套,那和当年 f-string 拼一拼再处理本质上没区别,只是换了个地方放。保持每个处理器短小、职责单一。

八、其它能用的场景

除了这三个,其实还有不少地方适合上 t-string。

i18n。t"Welcome, {name}" 交给翻译层,翻译器可以对消息做替换、复数处理、占位符重排,而不用担心值已经被拼进去了。gettext 那套现在只能吃字符串,未来支持 Template 之后误用会少很多。

路径拼接。校验每一段都是合法片段、防止 ../ 逃逸,逻辑和 SQL 参数化几乎一样。

Shell 命令构造。本来想让 t-string 做自动转义,但我想了想还是劝退。Shell 语义太复杂(引号、变量展开、通配符),t-string 抽象拿不住。真要拼命令就用 subprocess 的 list 形式,绕开 shell。

邮件与通知模板。一个处理器负责转义用户数据到 HTML,一个处理器负责纯文本版本,一个处理器负责提取字段做追踪。三份输出,一个数据源,不会前后不一致。

九、写在最后

t-string 不是 f-string 的替代品,它是 f-string 的补充。日常拼字符串该怎么写还是怎么写,f-string 依然是最顺手的工具。t-string 的价值在于那些「值不能随便拼」的场景——SQL、HTML、结构化输出,这些场景过去要么靠人记得写转义,要么靠一层专门的小 DSL,现在可以在语言级别表达出来。

如果手头有个项目正被 XSS 或者 SQL 注入的审计追着跑,可以拿一个模块先试点:把其中构造 HTML 或者 SQL 的 f-string 挑出来,改成 t-string 加处理器。改完之后再让静态分析工具跑一遍,看看漏洞是不是自己就少了。这种「写错会直接报类型错误」的安全感,试过一次就回不去了。

Python t-string 模板字符串实战:自定义插值处理与 SQL/HTML 安全输出
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 python Python t-string 模板字符串实战:自定义插值处理与 SQL/HTML 安全输出 https://www.taomawang.com/server/python/2809.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务