公共模块五样东西:泛型工具、持久化类、分页、字符串工具、实体映射。写一次,全项目都在调用。 所以它们的错误会放大到每一处;也正因为这样,这些代码值得多花时间写对。 分页最反直觉的一点:一次翻页是两条查询,慢的那条往往是算总数那条。
教材 24.5 讲公共模块设计:泛型工具类、数据持久化类、分页操作、字符串工具类、实体映射。 这五样在教材里分散在四五个小节,其实是一条线:把「每个模块都要写一遍」的代码抽出来。 抽出来的代价是「一处写错,处处错」,收益是「一处改对,处处对」。 所以这一篇的判断标准不是「会不会写」,是「值不值得抽」。 演示里用一个 Dao 类操作三张表,把分页的两条查询分开计时,再演示按字节截断汉字会截出什么。
一、五个轮子,一条判断标准
| 轮子 | 解决什么 | 不抽会怎样 |
|---|---|---|
| 泛型工具 | 一个方法适配多种类型 | 每张表抄一份增删改查 |
| 持久化类 | 把 SQL 收进一处 | SQL 散在各处,改表名要满仓库找 |
| 分页 | 页码、每页几条、总数 | 每个列表页自己算偏移量,算法还不一样 |
| 字符串工具 | 空判断、截断、转义 | 每个类各写一个 isEmpty |
| 实体映射 | 表字段与类属性的对应 | 手写 rs.getString("gname") |
这五行里,前四行是「省事」,第五行是「分层的边界」。
实体映射把「表」变成「类」以后,上层代码就再也不用碰 ResultSet 和列名了。
这也是 Hibernate 这类框架存在的理由:它让「改一个字段名」只影响两处(表、类),
而不是散落在几十条 SQL 里。
二、泛型 Dao:一个 save 管五张表
教材的泛型 Dao 长这样(Java,本沙箱不执行):
public class BaseDao<T> {
private Class<T> clazz;
public BaseDao(Class<T> clazz) { this.clazz = clazz; }
public T get(Serializable id) {
return (T) session.get(clazz, id);
}
public void save(T obj) { session.save(obj); }
public void delete(T obj) { session.delete(obj); }
}
// 用的时候一个泛型参数就够:
BaseDao<Goods> goodsDao = new BaseDao<>(Goods.class);
Class<T> 这一个参数,干的是三件事:决定查哪张表、决定把行组装成什么对象、
决定返回什么类型。
所以在 Java 里它必须写成泛型,否则调用方还得强转。
Python 里没有泛型擦除这一套,把「表名和列名」存成实例字段就够了——演示里就是这么做的,
效果一样:同一个 save 方法,往三张表里各插了一行。
三、分页:一条翻页是两条查询
「跳到第 2001 页」在数据库里是两件事:数总共有多少条,再取第 2001 页那 20 条。 两条的开销差得很远:
| 查询 | 实际做了多少事 | 耗时 |
|---|---|---|
COUNT(*) |
数完五万行 | 约一毫秒 |
| 取 20 条(第 1 页) | 只读二十行 | 约零点零五毫秒 |
| 取 20 条(第 2001 页) | 跨过四万行 | 约一毫秒 |
第一行比第二行慢十几倍——这就是「翻页慢」最常见的原因:慢在算总数,不在取数据。
第三行说明深翻页的代价:跨过四万行去取二十条,取数自己也能追上算总数。
所以用户等的是两条加起来,两条都要管。
页码还要在进 SQL 之前洗一遍:小于 1、超过总页数、根本不是数字,三种都要兜。
演示里五行测试只有一行是正常值,其余四行全靠代码兜住。
LIMIT 20 OFFSET -60 这种 SQL 在不同数据库上行为不一样,MySQL 直接报错,
所以不能指望数据库帮你挡。
四、字符串工具:三个空,两种截断
「空」在 Java 里是三个不同的东西:null、空串 ""、还有一串空格。
工具方法要一起判。判漏了会怎样?前端传一个空格过来,isEmpty 返回 false,
这个空格就一路带进数据库,之后按用户名查永远查不到。
截断比空判断更容易出错:
| 截法 | 截到哪 | 结果 |
|---|---|---|
| 按字节截 10 | 「据」字的第一个字节之后 | 丢一个字节,解出九个 |
| 按字符截 8 | 「据」字之后 | 干净,正好八个字 |
一个汉字在 UTF-8 里占三个字节,截断位置落在三的倍数之外就一定会劈开。 劈开之后解码只能丢掉那半个字——看起来只是「少了一个字」,实际上是「丢了一个字节的信息」, 换成 GBK 又对不上,因为那边两个字一个字。
五、动手 Demo:公共模块:泛型 Dao、分页、字符串工具
这一段用一个 BaseDao 类操作三张表(新增、按 id 查、删除),把分页的两条查询分开计时,
再演示页码越界的四种情况和按字节截断汉字的结果。
import sqlite3, time
class BaseDao(object):
"""复刻泛型 Dao:一个实例管一张表,方法只有一份。"""
def __init__(self, conn, table, cols, pk=u"id"):
self.c, self.t, self.cols, self.pk = conn, table, cols, pk
def get(self, oid):
return self.c.execute(
u"SELECT %s FROM %s WHERE %s = ?" % (u", ".join(self.cols),
self.t, self.pk), (oid,)).fetchone()
def save(self, row):
sql = u"INSERT INTO %s (%s) VALUES (%s)" % (
self.t, u", ".join(row.keys()), u", ".join(u"?" * len(row)))
self.c.execute(sql, tuple(row.values()))
return self.c.execute(u"SELECT last_insert_rowid()").fetchone()[0]
def delete(self, oid):
return self.c.execute(u"DELETE FROM %s WHERE %s = ?" % (self.t, self.pk),
(oid,)).rowcount
g = BaseDao(c, u"tb_goods", [u"id", u"gname", u"price"])
m = BaseDao(c, u"tb_member", [u"id", u"uname", u"upass"])
print(u"新增 id = %d" % g.save({u"gname": u"新商品", u"cat_id": 7, u"price": 9.9}))
print(u"按 id 查 %s" % (g.get(7),))
print(u"修改前的会员 %s" % (m.get(3),))
# 分页的两条查询,分开计时
def timed(fn, n=5):
best = 1e9
for _ in range(n):
t = time.perf_counter()
fn()
best = min(best, time.perf_counter() - t)
return best * 1000
print(u"COUNT(*) %.3f ms" % timed(
lambda: c.execute(u"SELECT COUNT(*) FROM tb_goods").fetchone()))
print(u"第 1 页 %.3f ms" % timed(
lambda: c.execute(u"SELECT id FROM tb_goods LIMIT 20").fetchall()))
print(u"第 2001 页 %.3f ms" % timed(
lambda: c.execute(u"SELECT id FROM tb_goods LIMIT 20 OFFSET 40000").fetchall()))
# 页码越界:四种情况都要兜
N, size = c.execute(u"SELECT COUNT(*) FROM tb_goods").fetchone()[0], 20
pages = (N + size - 1) // size
for raw in (u"1", u"0", u"-3", u"9999", u"abc"):
try:
p = min(max(int(raw), 1), pages)
except ValueError:
p = 1
print(u"page=%s -> 第 %d 页" % (raw, p))
# 按字节截断汉字
s = u"MySQL 数据库入门"
print(u"原文 %d 字节" % len(s.encode(u"utf-8")))
print(u"按字节截 10:%s" % s.encode(u"utf-8")[:10].decode(u"utf-8", u"ignore"))
print(u"按字符截 8:%s" % s[:8])
c.close()
实跑输出:
--------------------------------------------------------------------
公共模块:泛型 Dao、分页、字符串工具
--------------------------------------------------------------------
**教材 24.5 讲公共模块设计:泛型工具类、数据持久化类、分页操作、字符串工具类、实体映射。**
**这五样东西在项目里只写一次、被所有模块调用,所以一处写错会放大到每一处。**
① 泛型 Dao:一个 save 管五张表
--------------------------------------------------------------------
操作 商品表 会员表 订单表
-------------------------------------------------------------------------------------
新增(返回 id) id=50001 id=201 id=1
按 id 查 (7, '商品00007', 17.0) (3, 'member003', 'h3') (1, 'T-9001', 99.0)
删除(返回行数) 1 行 1 行 1 行
**同一个方法、同一份代码,靠「实例上记着表名和列名」决定去动哪张表。**
**Java 里这份「表名和列名」不是手写的,**
**而是从实体类的注解或 `hbm.xml` 里读出来的——这就是教材说的「实体映射」。**
② 分页:一次翻页其实是两条查询
--------------------------------------------------------------------
查询 耗时 实际做了多少事
------------------------------------------------
`COUNT(*)` 0.484 ms 数完五万行
取 20 条(第 1 页) 0.056 ms 只读二十行
取 20 条(第 2001 页) 0.966 ms 跨过四万行
**第一行比第二行慢 9 倍——这就是「翻页慢」最常见的原因:慢在算总数,不在取数据。**
**因为 `COUNT` 不取行也要数完全表,而取第一页只读二十行就返回了。**
**第三行是另一个方向的代价:跨过四万行去取二十条,取数自己也能追上算总数。**
**所以用户等的时间是两条加起来,两条都要管。**
**想省掉总数,就只给「上一页/下一页」,不给页码——这是懒加载分页的常见做法。**
③ 页码越界:三种写法三种结果
--------------------------------------------------------------------
传入 page 实际页 程序怎么处理
--------------------------------------------
1 1 正常
0 1 小于 1 → 回到第 1 页
-3 1 小于 1 → 回到第 1 页
9999 2500 超过总页数 → 落到最后一页
abc 1 非数字 → 回到第 1 页
**总共 2500 页。五行里只有第一行是正常的,其余四行都要靠代码兜住。**
**`LIMIT 20 OFFSET -60` 这种 SQL 在不同数据库上行为不一样:MySQL 直接报错,**
**SQLite 当成 0 处理。所以页码必须在进 SQL 之前洗一遍,不能指望数据库。**
④ 字符串工具:按字节截断会截出半个汉字
--------------------------------------------------------------------
原文 MySQL 数据库入门(11 字符/21 字节)
按字节截 10 MySQL 数(到手 9 字节)
按字符截 8 MySQL 数据
**第三行「按字节截 10」拿到手只有九个字节:第十个字节是「据」字的第一个字节,孤零零一个,**
**解码时只能丢掉。**
**这不是 bug,是必然——一个汉字在 UTF-8 里占三字节,截断位置落在三的倍数之外就一定会劈开。**
**第四行「按字符截 8」干净利落地停在「据」后面。**
**所以「截取摘要」这类工具方法必须按【字符】数,不能按字节数;**
**换成 GBK 又是两字节一个字,按字节算连「截多长」都对不上。**
**顺带一个空判断的坑:`null`、空串、全空白,三者在 Java 里是三个不同的东西。**
**工具方法要一起判,否则前端传一个空格过来,`isEmpty` 返回 false,后面一路带着这个空格跑。**
BaseDao 只有三个方法,但已经把「增、查、删」全覆盖了。
注意它一行 SQL 都没写死表名——表名、列名、主键名都是构造时传进去的。
这就是「一个方法管五张表」的全部秘密,不需要泛型也做得到;
Java 用泛型只是为了让调用方少写强转。
分页那三个数放在一起看:COUNT 和第 2001 页差不多慢,第 1 页快得多。
这说明「翻页慢」有两个来源,一个是算总数,一个是跨行数——只优化其中一个,另一个还在。
页码那五行,min(max(int(raw), 1), pages) 一行就把「太小」「太大」都收了,
try 收住「不是数字」。三种情况一个都不能漏。
按字节截断的对比很直观:截十个字节只解出九个字节,那一个字节就是被劈开的汉字碎片。
30 秒实验
- 给
BaseDao加一个update方法,让它只更新传进来的那几个字段。 - 把分页的
COUNT(*)换成SELECT MAX(id),重跑,看总数那条快了多少(代价是删除过后会算错)。 - 把
s[:8]改成s.encode(u"gbk")[:10].decode(u"gbk", u"ignore"),看截到哪。
常见误区
- 每个模块各写一份增删改查。表结构一改就要改十几处,而漏掉的那一处不会报错, 只会在某个页面悄悄出错。
- 分页只算数据不算总数。页码就成了摆设,用户点「末页」直接空白。 要么两条都查,要么干脆只给「下一页」。
- 按字节截摘要。英文看着没问题,中文一到边界就少字。摘要这类展示文本一律按字符数截。
自测三问(点开对答案)
1. 泛型 Dao 里 `Class轮子备齐了,下一篇搭环境:四份配置文件各管什么、启动顺序为什么不能反、 以及连接串里少一个参数会怎样。
- ☐ (本篇完,下一篇 24.6 项目环境搭建)