实体类的唯一职责是「搬运一行的数据」,里面一行业务规则都不该有。
Db 公共类只要四个方法:查表、取值、执行、开事务——够用一辈子。
参数化的第一理由不是防攻击,是「数据里带个单引号就会炸」——这是日常事故,不是安全事件。
前面三节都在说「要什么」,从这一节开始写代码。
但写的不是业务,是「所有业务都要用的那部分」。
这部分写好了,后面四个业务模块每个都能省一半代码。
教材 19.4 分两小节:各种实体类的编写、操作数据库公共类的编写。
两小节加起来不到十页,但是全书复用率最高的十页。
沙箱没有 C# 也没有 ADO.NET,所以这一节的 C# 原码照贴标「不执行」,
等价逻辑用 Python 的 namedtuple + 一个小类复刻并真跑——
要复刻的是「类怎么分工」,不是「语法怎么写」。
一、实体类:一个类对应一行
| C# 属性 | 对应列 | 类型为什么这么选 |
|---|---|---|
Id |
id |
int,自增主键 |
Eno |
eno |
string,工号有前导零 |
Hire |
hire |
string,日期当字符串传 |
DeptId |
dept_id |
int,外键用 id 不用名字 |
属性名跟列名一一对应,一个不多一个不少——这是刻意的,不是偷懒。
多一个属性,就有人在里面塞计算逻辑;少一个,就有人另外查一次。
Hire 用 string 而不是 DateTime,这是教材的做法,也是很多老项目的做法。
好处是「传什么存什么」,不会因为时区、格式在两头变形;坏处是「算不了日期差」。
要用日期函数的地方,得在 SQL 里转。
DeptId 存 id 不存部门名——因为部门会改名。
存名字的话,部门一改名,所有员工记录里的部门名都是旧的。
二、实体类里绝对不放业务规则
| 放进去的东西 | 后果 |
|---|---|
GetSalary() 算工资的方法 |
这个类被绑死在工资模块上 |
Validate() 校验工号格式 |
换个界面就得改这个类 |
Save() 直接写库 |
实体类和数据库绑死,没法单独测 |
| 只有属性,没有方法 | 谁都能用,谁都不依赖它 |
一个类如果只有属性、没有方法,它就叫「贫血模型」。
教科书上经常批评它,但在这种分层里,贫血恰恰是对的——
因为业务逻辑都在业务层,实体类只负责搬运。
判断标准很简单: 如果这个类的方法里出现了「另一张表」,它就已经越界了。
算工资要读考勤表,所以算工资的方法必须在业务层,不能在 Employee 里。
三、Db 公共类:四个方法的分工
| 方法 | 返回什么 | 典型用途 |
|---|---|---|
Query |
一张表(DataTable) |
列表、报表 |
Scalar |
一个值 | COUNT(*)、取最大 id |
Execute |
影响行数(int) |
增删改 |
Begin |
一个事务对象 | 要么全成的多步写 |
四个方法够用一辈子。 多出来的常见是「QueryPage 分页查询」——
但这个用 Query 传一条带 OFFSET ... FETCH 的 SQL 就够了,不必单开方法。
「公共类」的意义不在省代码,在于「只有一处要改」。
换连接字符串、加日志、加超时、加重试,全改这一个类——一百个窗体不用动一行。
这也是「分层」最实在的理由。 不分层不会马上出错,只会在第 3 次改需求的时候开始疼。
而那时候项目已经上线了。
四、参数化:不是因为有人攻击,是数据自己会炸
| 写法 | 普通姓名 | 姓里有单引号 |
|---|---|---|
| 拼接字符串 | 查得到 | SQL 语法错误 |
| 参数化 | 查得到 | 正常执行,查不到 |
这里一个坏人都没有,只是一个姓带撇的外籍员工。
'O'Brien' 里那个单引号把 SQL 提前闭合了——
'O' 是一个字符串,Brien' 就成了看不懂的东西。
所以参数化的第一理由是「正常数据会出错」,不是「有人会攻击」。
安全是顺带的好处,而且是大好处——同一个机制,顺手把注入也堵死了。
还有个常见误解: 「参数化会慢」。
恰恰相反——参数化的语句可以被数据库缓存执行计划,重复执行反而更快。
拼接的 SQL 每次文本都不一样,每次都当新语句解析。
五、动手 Demo:实体类、Db 公共类、参数化对比
这一段做三件事:用 namedtuple 复刻实体类、写一个四方法的 Db 类、
对比拼接与参数化在「姓带单引号」时的表现。
第三件是重点——它证明参数化不是为了防谁,是为了自己的数据能进去。
from collections import namedtuple
Employee = namedtuple(
'Employee', 'id eno ename sex dept_id post_id hire state')
e = Employee(6, 'E006', '周雅静', '女', 6, 4, '2020-07-01', '在职')
print(e.eno, e.ename, e.dept_id)
class Db:
def __init__(self, conn):
self.c = conn
def query(self, sql, args=()):
cur = self.c.execute(sql, args)
cols = [d[0] for d in (cur.description or [])]
return cols, cur.fetchall()
def execute(self, sql, args=()):
return self.c.execute(sql, args).rowcount
def scalar(self, sql, args=()):
return self.c.execute(sql, args).fetchone()[0]
db = Db(c)
print(db.query('SELECT eno, ename FROM v_emp WHERE dept = ?', ('研发一组',)))
print(db.scalar("SELECT COUNT(*) FROM tb_employee WHERE state='在职'"))
# 拼接 vs 参数化
bad = "SELECT eno FROM tb_employee WHERE ename = '%s'"
try:
c.execute(bad % "O'Brien")
except Exception as ex:
print('拼接报错:', ex)
print('参数化:', c.execute(
'SELECT eno FROM tb_employee WHERE ename = ?', ("O'Brien",)).fetchall())
实跑输出:
--------------------------------------------------------------------
公共模块:实体类与数据库公共类
--------------------------------------------------------------------
① 实体类:一个类对应一行
--------------------------------------------------------------------
[教材原码 · 本沙箱不执行]
| public class Employee
| {
| public int Id { get; set; }
| public string Eno { get; set; }
| public string Ename { get; set; }
| public string Sex { get; set; }
| public int DeptId { get; set; }
| public int PostId { get; set; }
| public string Hire { get; set; }
| public string State { get; set; }
| }
字段名跟表列名一一对应,一个不多一个不少——这是刻意的。
实体类的唯一职责是「搬运一行的数据」,里面不写任何业务规则。
一旦有人在实体类里加上「算工资」的方法,这个类就被绑死在工资模块上了。
Python 里就是一个 dict 或者小类。 下面用 namedtuple 复刻同一件事:
id = 6 eno = E006
ename = 周雅静 sex = 女
dept_id = 6 post_id = 4
hire = 2020-07-01 state = 在职
一行数据在内存里长什么样,比它在表里长什么样更重要——因为代码里所有传递都是传这个对象。
② 公共类 Db:四个方法就够
--------------------------------------------------------------------
[教材原码 · 本沙箱不执行]
| public class Db
| {
| private static string conn =
| "Server=.;Database=db_hr;User Id=sa;Password=你的密码";
|
| public static DataTable Query(string sql, params SqlParameter[] ps) {...}
| public static int Execute(string sql, params SqlParameter[] ps) {...}
| public static object Scalar(string sql, params SqlParameter[] ps) {...}
| public static SqlTransaction Begin() {...}
| }
教材里的 Db 类就是这四个方法,全书所有窗体都只调它。
query → 列:eno/ename/dept/post
E005 | 孙浩 | 研发一组 | 主管
E006 | 周雅静 | 研发一组 | 工程师
scalar → 在职人数 = 8
execute→ 受影响行数 = 1
四个方法的分工:Query 返回表、Scalar 返回一个值、Execute 返回影响行数、Begin 开事务。
够用一辈子。
「公共类」的意义不在于省代码,在于「只有一处要改」。
换连接字符串、加日志、加超时,全改这一个类,一百个窗体不用动。
③ 参数化:不是为了防攻击,是正常数据就炸
--------------------------------------------------------------------
拼接 · 普通姓名 → 查到了 1 行
拼接 · 带单引号 → 直接报错:near "Brien": syntax error
参数化 · 带单引号 → 正常执行,查到 0 行(没有这个人)
这里一个「坏人」都没有,只是一个姓带撇的外籍员工。
拼接就炸了—— 'O'Brien' 里那个单引号把 SQL 提前闭合了。
所以参数化的第一理由不是安全,是「数据里如果有特殊字符就会出错」。
安全是顺带的好处,而且是大好处。
④ 公共类的反面:每个窗体各写一份
--------------------------------------------------------------------
| 写法 | 改连接串要改几处 | 加日志要改几处 |
|---|---:|---:|
| 每个窗体各写一份 | 7 处(七张表七个窗体) | 7 处 |
| 统一走 Db 公共类 | 1 处 | 1 处 |
这张表看着平淡,但它是「分层」最实在的理由。
不分层不会马上出错,只会在第 3 次改需求的时候开始疼。
namedtuple 是 Python 里最接近「贫血实体类」的东西——
它只有字段,没有方法(有几个通用方法,但不是业务方法)。
要改一个值得整个换掉,这也是对的:实体对象本来就该当成不可变的值来传。
Db.query 返回的 cols 很重要。 界面上表格的列名就是它——
没有它就得手工写死列头,SQL 改一列界面就错位。
第三节那个对比里,两个结果都不算「成功」:一个报错,一个查不到。
但「查不到」是正确结果(确实没有这个人),
「报错」是程序缺陷——区别在这里,不在有没有查到数据。
30 秒实验
- 给
Db加一个query_one(只取第一行),看能省掉多少rows[0]。 再想想:加第五个方法,会不会破坏「四个够用」的原则? - 把参数化那行改成拼接,传
'周雅静', 确认普通姓名拼接也能查出来——这样才能说明「问题出在特殊字符,不是拼接本身不能用」。 - 想一想:如果
Db类里加一行「记录每条 SQL 到日志文件」,要改几个地方? 一个。这就是公共类的价值——不分层的话要改七个。
常见误区
- 「实体类里顺便加个计算方法方便」。 方便一次,绑死一辈子。 方法里一出现别的表,就说明它该在业务层。
- 「参数化是防黑客的,我们内部系统不用」。 姓带单引号就会炸,这不是黑客,是正常数据。
- 「每个窗体自己连数据库更直接」。 改连接串时要改七处,漏一处就有一个窗体连不上。 这不是「更直接」,是「更早埋雷」。
自测三问(点开对答案)
1. 实体类为什么只能有属性? —— **因为它只负责「搬运一行数据」。** **一加方法就绑死某个模块**,**一读写别的表就越界了。** 2. `Db` 类四个方法各自的返回值是什么? —— **`Query` 返回表、`Scalar` 返回一个值、`Execute` 返回影响行数、`Begin` 返回事务对象。** **判断用哪个:要一行还是多行、要不要写。** 3. 为什么说参数化的第一理由不是安全? —— **因为正常数据里带单引号就会让拼接的 SQL 语法错。** **这是日常事故。** **安全是顺带的好处。**下一篇 19.5,用这个 Db 类写登录:
四步校验、加盐慢哈希、三次锁定——顺便看看「密码对了却登不进去」是怎么发生的。
- ☐ (本篇完,下一篇 19.5 系统用户登录模块设计)