🛢️ 第24章 Struts2+Spring+Hibernate+MySQL实现网络商城
📄 一句话概括

公共模块五样东西:泛型工具、持久化类、分页、字符串工具、实体映射。写一次,全项目都在调用。 所以它们的错误会放大到每一处;也正因为这样,这些代码值得多花时间写对。 分页最反直觉的一点:一次翻页是两条查询,慢的那条往往是算总数那条。

📝 本章和前面几章的关系

教材 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 秒实验

  1. 给 BaseDao 加一个 update 方法,让它只更新传进来的那几个字段。
  2. 把分页的 COUNT(*) 换成 SELECT MAX(id),重跑,看总数那条快了多少(代价是删除过后会算错)。
  3. 把 s[:8] 改成 s.encode(u"gbk")[:10].decode(u"gbk", u"ignore"),看截到哪。

常见误区

  1. 每个模块各写一份增删改查。表结构一改就要改十几处,而漏掉的那一处不会报错, 只会在某个页面悄悄出错。
  2. 分页只算数据不算总数。页码就成了摆设,用户点「末页」直接空白。 要么两条都查,要么干脆只给「下一页」。
  3. 按字节截摘要。英文看着没问题,中文一到边界就少字。摘要这类展示文本一律按字符数截。
自测三问(点开对答案) 1. 泛型 Dao 里 `Class` 参数有什么用? —— 它同时决定了查哪张表、把行组装成什么对象、返回什么类型。 没有它,调用方就得自己强转,而且「表名」也没地方拿。 2. 一次翻页为什么要两条查询?哪条更慢? —— 一条算总数(给页码用),一条取当前页。通常是算总数那条慢,因为它要数完全表; 深翻页时取数据那条也会追上来。 3. 按字节截断汉字会发生什么? —— 截断位置落在三的倍数之外就会劈开一个汉字,解码时那半个字被丢掉。 结果是「少了一个字」,但实际是「丢了一个字节」,换编码就对不上。

轮子备齐了,下一篇搭环境:四份配置文件各管什么、启动顺序为什么不能反、 以及连接串里少一个参数会怎样。

相关笔记