🧪 第9章 建立搜索功能
📄 一句话概括

搜索不过是给列表页加一个过滤条件:join 到标签表,ilike 加两个百分号。 distinct 不能省,一张图命中两个标签就会在页面上出现两行。 % 和 _ 是 LIKE 的通配符,用户输一个百分号就能让全表出来。

📝 本章和前面几章的关系

这一篇对应教材 9.1 到 9.3:建立图片搜索功能的端点、模板,以及确认运作情况。 第 8 章做完检测,库里有标签了,这一篇让标签能被搜出来。

一、端点的四条要点

q = request.args.get("q", "").strip()
rows = db.session.execute(
    db.select(UserImage)
    .join(UserImageTags)
    .where(UserImageTags.tag.ilike("%" + q + "%"))
    .order_by(UserImage.id).distinct()
).scalars().all()

五行,四个要点:取值去空格、join 到标签表、两个百分号包起来、distinct 去重。 distinct 不能省。一张图有两个标签都命中时,join 会把它返回两次,页面上就出现两行一样的图。 用户看到的第一反应是「数据库坏了」。 ilike 是 PostgreSQL 的写法,SQLite 里也能用。 差别在底层:SQLite 的 LIKE 本来就不区分大小写,但只对 ASCII 有效; 换成 PostgreSQL 之后,大小写的行为才真的由 ilike 决定。

返回值用 .scalars().all() 拿模型对象,而不是 .all() 拿 Row 元组。 模板里直接写 r.filename、r.tags 都行。

二、搜不到是 200,不是 404

关键字 命中
dog 2 张
cat 1 张
bird 0 张

三种情况返回的都是 200。搜不到的时候页面上多一句「没有找到带这个标记的图片」。 搜索无结果是一种正常状态,不是错误。返回 404 的话,浏览器会跳到错误页, 用户搜个没见过的词就被判定「页面不存在」,体验很怪。 这也解释了为什么搜索页跟错误页要分开:一个说「没有」,一个说「不对」。

三、大小写与部分匹配

写法 命中 为什么
DOG 2 张 跟搜 dog 一样,ASCII 不区分大小写
Person 2 张 数据里存 Person,小写的 person 也命中
do 2 张 两边带 %,是「包含」不是「等于」
ar 1 张 命中的是 car 中间那两个字母

ar 这条最能说明「包含」的意思:car 里既不以 ar 开头也不以 ar 结尾,照样命中。 顺手记住一件事:前面带 % 的 LIKE 用不上索引。 数据库的索引是按前缀排的,你告诉它「末尾是什么我不知道」,它只能一行一行看。 表小的时候没感觉,几百万行就是几秒钟。

真做搜索得上 FTS(SQLite 有 FTS5,PostgreSQL 有 tsvector),或者干脆交给专门的搜索引擎。 这是另一个话题,但要知道这里是个天花板。

四、坑一:百分号是通配符

输入 命中
% 4 张(全中)
_ 4 张(全中)

库里 5 张图,4 张有标签,所以「全中」是 4。 这不是 bug,是 LIKE 的语义:% 匹配任意长度的任意字符,_ 匹配单个字符。 用户输一个百分号,拼出来就是 like '%%%',等于什么都匹配。 要按字面搜,得先转义再声明转义字符:

q = q.replace("%", "\\%").replace("_", "\\_")
where(UserImageTags.tag.ilike("%" + q + "%", escape="\\"))

大多数搜索框都忘了这一步。不转义不会报错、不会崩溃,只是搜一个百分号会出来全部, 没人会去搜百分号,所以永远发现不了。

五、坑二:拼接字符串就是注入

把 ORM 换成拼字符串,同一个搜索词,结果完全不同:

写法 搜 zz' or 1=1 -- 命中
拼字符串 like '%zz' or 1=1 --%' 4 张
ORM 参数化 值原样当参数 0 张

拼出来的那句 SQL 里,-- 把后面那截注释掉了,剩下 like '%zz' or 1=1。后半段恒真,全表都出来了。 这个例子里只是多读了几行。换成 drop table 或者 union select 就是另一种后果了。 参数化不是「更安全」,是「代码和数据在物理上分开了」。走参数化之后, 那个引号只是值里的一个字符,它没有机会变成 SQL 的语法。

这一条跟第 5 章那个「f-string 拼 SQL」是同一个东西,只是那次是查询条件, 这次是用户输入——后者才是真的有人会去试。

六、坑三:空字符串

实测搜空串、搜三个空格,都返回 0 张。 靠的是两处:request.args.get("q", "") 给个默认空串,if q: 把空串挡在外面。 少了 if q: 那一句,"%" + "" + "%" 就是 %%,匹配所有标签。 搜索页一打开就列出全部图片,跟列表页没区别。

strip() 也不能少:用户复制粘贴常带前后空格,不去掉的话搜 "dog " 就是 %dog %, 尾巴那个空格让它在很多情况下搜不到。

七、模板三件事

<h2>「{{ q }}」的搜索结果({{ rows|length }} 张)</h2>
{% if not rows %}<p>没有找到带这个标记的图片。</p>{% endif %}

第一,把关键字显示在标题里。 用户能确认自己搜的是什么,而不是对着一个空结果猜「我刚才打的是 dog 还是 dogs」。 第二,空结果给一句话。空白页会让人以为页面坏了。 第三,不用 |safe。{{ q }} 默认转义,用户输 <script> 只会显示成字面量; 加了 |safe 就真执行了——那是自己给自己开的洞。

结果行里显示的是这张图的全部标签,不是命中的那一个。 因为一张图命中,用户想看的是「它整体被标了什么」。

八、这一篇的位置

搜索是第 2 篇的最后一件事。到这里,这个物件侦测应用该有的功能都有了:上传、检测、看结果、搜、删。 接下来两章都是「收尾」性质的:第 10 章管出错的时候长什么样,第 11 章管怎么证明它没坏。 功能做得再多,出错页是一屏英文堆栈,用户照样不敢用。

九、动手 Demo:搜索:一条 LIKE 背后的三个坑

这个 Demo 造 5 张图 6 条标签,先把正常的搜索跑一遍,再把三个坑挨个踩下去。 第五节那个百分号、第六节那次真注入,是这一篇最该带走的两件事。

@bp.route("/search")
@login_required
def search():
    q = request.args.get("q", "").strip()
    rows = []
    if q:
        rows = db.session.execute(
            db.select(UserImage)
            .join(UserImageTags)
            .where(UserImageTags.tag.ilike("%" + q + "%"))
            .order_by(UserImage.id).distinct()
        ).scalars().all()
    return render_template("images/search.html", rows=rows, q=q)

实跑输出:

--------------------------------------------------------------------
搜索:一条 LIKE 背后的三个坑
--------------------------------------------------------------------
  造了 5 张图、6 条标签

  ① 端点长什么样
  --------------------------------------------------------------------

  [端点核心]
  | q = request.args.get("q", "").strip()
  | rows = db.session.execute(
  |     db.select(UserImage)
  |     .join(UserImageTags)
  |     .where(UserImageTags.tag.ilike("%" + q + "%"))
  |     .order_by(UserImage.id).distinct()
  | ).scalars().all()
  join 到标签表,ilike 做不区分大小写的模糊匹配,distinct 去重。

  ! distinct 不能省:一张图有两个标签都命中时,join 会把它返回两次,页面上就出现两行一样的图。
  ! ilike 是 PostgreSQL 的写法,SQLite 里也能用。
  差别在底层:SQLite 的 LIKE 本来就不区分大小写,但只对 ASCII 有效,中文没有大小写问题。
  换成 PostgreSQL 之后,大小写的行为才真的由 ilike 决定。

  ② 正常搜一次
  --------------------------------------------------------------------
  搜 dog -> 200,命中 2 张
  搜 cat -> 200,命中 1 张
  搜 bird -> 200,命中 0 张
  ! 搜不到返回的是 200 加一句「没有找到」,不是 404。搜索无结果是一种正常状态,不是错误。

  ③ 大小写
  --------------------------------------------------------------------
  搜 DOG -> 命中 2 张(跟搜 dog 一样)
  搜 Person -> 命中 2 张;搜 person -> 命中 2 张
  ! 数据里存的是 Person(大写 P),搜小写也命中。SQLite 的 LIKE 对 ASCII 不区分大小写。

  ④ 只输一部分
  --------------------------------------------------------------------
  搜 do -> 命中 2 张
  搜 ar -> 命中 1 张
  ! 两边都加了 %,所以是「包含」而不是「等于」。搜 ar 能命中 car。
  ! 这也是它慢的原因:前面带 % 的 LIKE 用不上索引,表大起来只能全表扫。
  真要做搜索,得上 FTS 或者专门的搜索引擎。

  ⑤ 坑一:% 和 _ 是通配符
  --------------------------------------------------------------------
  搜一个百分号 -> 命中 4 张(全中)
  搜一个下划线 -> 命中 4 张
  ! 用户输一个 %,等于让 SQL 匹配所有东西。
  这不是 bug,是 LIKE 的语义:% 匹配任意长度,_ 匹配单个字符。
  ! 要按字面搜,得转义:q.replace("%", "\%").replace("_", "\_"),再在 SQL 后面加 ESCAPE '\'。
  大多数搜索框都忘了这一步。

  ⑥ 坑二:拼接字符串就是注入
  --------------------------------------------------------------------
  拼字符串的版本,搜 "zz' or 1=1 -- "
  -> 200,命中 4 张(本该 0 张)
  ORM 那版同一个输入 -> 命中 0 张
  ! 拼出来的 SQL 是 where tag like '%zz' or 1=1 --%'。
  -- 把后面那截注释掉了,剩下 like '%zz' or 1=1——后半段恒真,全表都出来了。
  ! 这不是「更安全」,是「代码和数据在物理上分开了」:走参数化之后,那个引号只是值里的一个字符,
  进不了 SQL 的语法。

  ⑦ 坑三:搜空字符串
  --------------------------------------------------------------------
  搜空串 -> 200,命中 0 张
  搜三个空格 -> 200,命中 0 张
  ! 都返回 0 张。
  端点里 if q: 那句把空串挡在外面了——不然 % 会匹配所有标签,搜索页一打开就列出全部图片,
  跟列表页没区别。
  ! strip() 也是必须的:用户复制粘贴常带前后空格,不去掉就搜不到。

  ⑧ 模板
  --------------------------------------------------------------------

  [代码原样]
  | <h2>「{{ q }}」的搜索结果({{ rows|length }} 张)</h2>
  | {% if not rows %}<p>没有找到带这个标记的图片。</p>{% endif %}
  把用户输的关键字原样显示在标题里,能让人确认自己搜的是什么。

  ! {{ q }} 默认会被转义,所以输 <script> 只会显示成字面量。
  用 |safe 才会真的执行——那是自己给自己开洞。
  ! 结果里显示的是这张图的所有标签,不是命中的那个。
  因为一条图片命中,用户想看的是它整体被标了什么。

第五节那两个 4 张:库里有 5 张图,4 张带标签,所以「全中」是 4。 第六节两个数必须并排看:拼字符串 4 张、ORM 0 张,同一个输入。 第七节的 0 张是 if q: 挡下来的,不是搜不到。

30 秒实验

  1. 把 .distinct() 删掉,给一张图打两个都含 do 的标签,再搜 do。
  2. 把 if q: 改成 if True:,打开一次搜索页看结果。
  3. 想一想:为什么前面带 % 的 LIKE 用不上索引?

常见误区

  1. 不加 distinct:一张图命中两个标签就出现两行。
  2. 搜不到就返回 404:搜索无结果是正常状态,不是页面不存在。
  3. 不转义 % 和 _:用户输一个百分号就全表出来。
  4. 用 f-string 拼搜索条件:这次的输入真的来自用户。
自测三问(点开对答案) 1. 为什么 join 之后要 distinct? —— 一张图有多个标签命中时会被返回多次,页面上重复显示。 2. 搜一个百分号为什么会全中? —— % 是 LIKE 的通配符,匹配任意长度,得先转义再声明 escape 字符。 3. 参数化为什么能防注入? —— 代码和数据分开了,值里的引号只是字符,进不了 SQL 语法。

第 9 章到此结束。下一篇做出错页:404 的时候用户看到什么。

相关笔记