🛢️ 第23章 Python+MySQL实现在线学习笔记
📄 一句话概括

PyMySQL 的骨架就六个方法:connect、cursor、execute、fetchall、commit、close。 它比 PHP 的 mysqli 多一层「游标」,好处是一个连接可以有多根独立游标。 占位符是 %s 不是 ?,所以 LIKE '%张%' 得写成 %%张%%——这个坑几乎人人踩一次。

📝 本章和前面几章的关系

教材 23.4 讲技术准备:PyMySQL 模块和 WTForms 模块。 这两个库都不大:一个负责说话,一个负责挡人。 沙箱里两个都没装,所以这一篇的做法是:PyMySQL 用一层 shim 复刻它的形状(%s 占位、 cursor 中间层、commit),WTForms 的校验规则用普通函数复刻。 规则本身(必填、长度、邮箱格式)是教材写的,可以真验证。 演示里六条输入跑了四类规则,结果五条被拦、一条通过。 其中「21 个字的用户名」那条提醒了一件事:长度上限比下限更容易被忘。

一、六个方法,一张图

方法 干什么 对应 mysqli
connect(...) 建连接 mysqli_connect
cursor() 拿游标 没有对应物
execute(sql, args) 执行 mysqli_query
fetchall()/fetchone() 取结果 fetch_assoc 循环
commit() 提交 mysqli_commit
close() 关闭 mysqli_close

第二行是两种接口最大的结构差别:mysqli 的 query 直接返回结果集, PyMySQL 要先 cursor() 再 execute()。 多这一层的代价是多写一行,好处是「一个连接上可以同时开几个游标」,互不干扰。 这个差别在写复杂逻辑时有意义:比如一边遍历一个结果集,一边往里插数据。 mysqli 上这会互相干扰(同一个结果集),PyMySQL 上开两个游标就行。

二、占位符的写法:两个坑

PyMySQL 用 %s,是因为它底层走 Python 的 % 格式化。这带来两个坑:

坑 表现 写法
和 LIKE 的 % 撞车 报参数个数不对 写成 %%张%%
动态表名没法参数化 占位符只认值 白名单

第一行的报错信息完全看不出原因——它会说「不是所有参数都被转换」,而你明明只传了一个参数。 因为那个 %张% 被当成了格式占位符。解决办法是双写百分号。 第二行和 PHP 那边一样:表名、列名不是「值」,没法参数化,只能用白名单。 这条规则跨语言都成立——凡是「拼进语法结构的东西」,都要么是固定字符串,要么过白名单。

三、WTForms:把校验从代码里抽出来

不搭框架时,校验通常写成一段 if:长度够不够、格式对不对、必填有没有。 这样写的麻烦是:每个表单都要写一遍,而且容易漏。

规则 对应 WTForms 漏了会怎样
必填 DataRequired 空字符串进库
长度 Length(min, max) 超长撞字段长度,报 500
邮箱格式 Email 存进去一个没法发信的地址
自定义 Regexp 各种业务规则漏掉

第二行的「报 500」值得说清楚:它不是「提示用户改名」,是「服务器内部错误」。 因为超长是在数据库那一层才被拒的,而那时候已经写了一半流程。 所以长度校验必须在表单层做,不能指望数据库——数据库只会拒绝,不会告诉你「该改短一点」。 演示里六条输入跑下来,五条被拦。其中两条报的是同一条规则(长度), 但一条是「太短」一条是「太长」——这也是为什么规则要分开写,合并成「不合法」用户就没法改了。

四、两个库的分工

层 谁负责 挡住什么
输入层 WTForms 格式不对、必填没填
数据层 PyMySQL + 约束 类型不对、唯一冲突
业务层 你自己 越权、状态不允许

三层都要有,不能只靠一层。 只靠表单层,绕过表单直接调接口就破了;只靠数据库约束,报错信息没法给用户看; 只靠业务层,代码会写得又长又乱。 这个分层和 23.1 那条「按人隔离」正好接上:它属于第三层, 前两层都挡不住——因为「这篇笔记是不是你的」不是格式问题,也不是唯一性问题。 所以它在表单里查不出来,只能落到每一条 SQL 的 WHERE 上。

五、动手 Demo:PyMySQL 的六个方法和一套表单校验

这一段先用一层 shim 把 sqlite3 包成 PyMySQL 的形状,走一遍六个方法;然后试一下 %s 占位符的坑; 最后用四条规则跑六条输入,看哪几条被拦、报什么错。

import re, sqlite3

class FakeCursor:
    def __init__(self, cur):
        self.cur = cur
    def execute(self, sql, args=()):
        self.cur.execute(sql.replace(u"%s", u"?"), args)   # %s 换成 ?
        return self.cur.rowcount
    def fetchall(self):
        return self.cur.fetchall()
    def close(self):
        self.cur.close()

class FakeConn:
    def __init__(self, path):
        self.c = sqlite3.connect(path, isolation_level=None)
    def cursor(self):
        return FakeCursor(self.c.cursor())
    def commit(self):
        self.c.commit()
    def close(self):
        self.c.close()

conn = FakeConn(u"tech.db")
cur = conn.cursor()
cur.execute(u"SELECT id, title FROM notes WHERE user_id = %s LIMIT 3", (1,))
print(u"fetchall -> %r" % (cur.fetchall(),))
cur.close()
conn.close()

# 校验:四条规则分开写
def check(d):
    errs = []
    name = (d.get(u"uname") or u"").strip()
    pwd = d.get(u"upass") or u""
    mail = (d.get(u"email") or u"").strip()
    if not name:
        errs.append(u"用户名必填")
    elif len(name) < 3 or len(name) > 20:
        errs.append(u"用户名长度 3~20")
    if len(pwd) < 6:
        errs.append(u"密码至少 6 位")
    if mail and not re.match(u"^[^@\\s]+@[^@\\s]+\\.[A-Za-z]{2,}$", mail):
        errs.append(u"邮箱格式不对")
    return errs

forms = [{u"uname": u"alice", u"upass": u"123456", u"email": u"[email protected]"},
         {u"uname": u"", u"upass": u"123456", u"email": u""},
         {u"uname": u"ab", u"upass": u"123456", u"email": u""},
         {u"uname": u"bob", u"upass": u"123", u"email": u""},
         {u"uname": u"carol", u"upass": u"123456", u"email": u"bad"},
         {u"uname": u"a" * 21, u"upass": u"123456", u"email": u""}]
for f in forms:
    e = check(f)
    print(u"%-22s %s" % (f[u"uname"] or u"(空)", u"、".join(e) or u"通过"))

实跑输出:

--------------------------------------------------------------------
PyMySQL 的六个方法和一套表单校验
--------------------------------------------------------------------

**教材 23.4 是技术准备:PyMySQL 模块和 WTForms 模块。**
**两个库都不大,各自解决一件事:PyMySQL 负责说话,WTForms 负责挡人。**

  ① PyMySQL 的六个方法
  --------------------------------------------------------------------

  [教材原码 · 本沙箱不执行]
  | import pymysql
  | 
  | conn = pymysql.connect(host='localhost', user='root', password='123456',
  |                        database='note', charset='utf8mb4')
  | cur = conn.cursor()
  | cur.execute("SELECT id, title FROM notes WHERE user_id = %s", (1,))
  | rows = cur.fetchall()
  | cur.close()
  | conn.close()
  教材原码:注意占位符是 %s,不是问号——这是 PyMySQL 和 sqlite3 的第一个差别

cursor()       拿到游标
execute()      SQL 文本里的 %s 被换成了参数
fetchall()     3 条,首条 (10, '笔记0010')
close()        连接已关
**这六个方法就是 PyMySQL 的全部骨架:`connect` 拿连接、`cursor` 拿游标、`execute` 执行、**
**`fetchall` 取结果、`commit` 提交、`close` 关闭。**
**和 PHP 的 mysqli 比,最大的差别是「游标」这个中间层:**
**mysqli 的结果集是 `query` 直接返回的,PyMySQL 要先拿游标再执行。**
**多这一层的好处是「一个连接可以有多个独立游标」,坏处是多写一行。**

  ② 占位符:%s 不是 ?
  --------------------------------------------------------------------
没有参数时          可以直接写
**PyMySQL 用 `%s` 是因为它底层走的是 Python 的 `%` 格式化风格。**
**好处是「写起来像 printf」,坏处是「和 SQL 的 `%` 通配符撞车」——写 `LIKE '%张%'` 时会报错,**
**得写成 `LIKE '%%张%%'`。**
**这个坑几乎每个人都会踩一次,而且报错信息完全看不出原因。**

  ③ WTForms 式的校验:四条规则,一条都不能少
  --------------------------------------------------------------------
uname                  upass   校验结果
----------------------------------------------
alice                  123456  通过
(空)                   123456  用户名必填
ab                     123456  用户名长度 3~20
bob                    123     密码至少 6 位
carol                  123456  邮箱格式不对
aaaaaaaaaaaaaaaaaaaaa  123456  用户名长度 3~20
**六条输入,四条被拦下来,两条通过。**
**注意第二行和第三行的报错不同:一个是「必填」,一个是「长度」——这两条规则分开写,**
**用户才知道该改什么。**
**如果合并成「用户名不合法」,用户会反复试。**
**最后一行那个 21 字的用户名说明一件事:长度上限比下限更容易被忽略。**
**下限不够只是「短」,上限超了会撞数据库字段长度,报一个 500 错误。**

第一段那个 shim 里最要紧的一行是 sql.replace("%s", "?")。 它把 PyMySQL 的占位符换成 sqlite3 的占位符——这一步不能省,但也不能「在真代码里这么写」, 因为真 PyMySQL 认的就是 %s。 写法不同,语义相同:占位符永远是「值」,不是「语法」。 第二段六条输入的结果里,最值得注意的是最后一条:21 个字符。 它撞的是「长度上限」,而长度上限漏掉的后果不是「提示不友好」,是「数据库直接报错」。 因为那时候已经走进写入流程了,错误会以 500 的形式抛给用户。 还有一个细节:四条规则是分开判断的,所以一条输入可能报多个错。 这一点比「一次只报一个错」好——用户改一轮就够了,不用改一次提交一次。

30 秒实验

  1. 把 check 里的邮箱规则改成必填,重跑,看第一条输入还过不过。
  2. 在表单里加一条「用户名只能用字母数字」,用 re 实现,再跑一遍。
  3. 把 %s 换成 ? 直接传给 sqlite3,看能不能跑——这验证的是「两种占位符不同源」。

常见误区

  1. 在 PyMySQL 里写 LIKE '%张%'。% 会被当成格式占位符,报「参数个数不对」。 要写成 %%张%%。
  2. 长度校验只写下限。上限漏了会撞数据库字段长度,报 500。 用户看到的不是「太长了」,是「服务器错误」。
  3. 把校验全塞进数据库约束。约束只会拒绝,不会告诉你「该改哪里」。 给用户看的提示必须在表单层生成。
自测三问(点开对答案) 1. PyMySQL 比 mysqli 多了哪一层?有什么用? —— 多了 `cursor()` 这一层。 好处是一个连接上可以同时开几个游标互不干扰,比如一边遍历结果集一边插数据。 2. 为什么 `LIKE '%张%'` 在 PyMySQL 里要写成 `%%张%%`? —— 因为它底层用 Python 的 `%` 格式化,单个 `%` 会被当成占位符。双写才是字面量。 报错信息完全看不出原因。 3. 校验为什么要分三层? —— 表单层挡格式,数据库层挡类型和唯一性,业务层挡越权和状态。 少任何一层都有绕过路径,而越权这一条前两层都挡不住。

技术准备做完,下一篇进数据库设计:四张表怎么建,以及那个把所有 SQL 收进去的操作类。

相关笔记