🏺 第19章 企业人事管理系统
📄 一句话概括

公共模块是三件事:一份配置文件、一批映射规则、一组增删改查方法。 三件事都能真跑:XML 真解析,映射真生成,CRUD 真执行。 它决定后面三个业务模块能省多少代码。 但它也决定「事务边界在哪」——这一条比省代码重要得多。

📝 本章和前面几章的关系

19.5 把窗体的骨架搭好了,这一篇往骨架里填第一个可复用的东西。 第 6 章讲过程、函数、包,那是数据库侧的「复用」。 这一篇是应用侧的复用——同样的思路,换到 Java 这边。 19.7~19.9 三个模块都会调这里的方法,所以这一篇是那三篇的地基。

一、配置文件:六项里四行是必须的

Hibernate 的主配置就一个 XML,核心是 session-factory 里的几个 property。 教材那个文件有六项,其中四项缺了就跑不起来。

配置项 值 缺了会怎样
dialect Oracle9Dialect 启动报「找不到 dialect」
driver_class oracle.jdbc.driver.OracleDriver 连不上库
connection.url jdbc:oracle:thin:@… 连不上库
username/password scott/tiger 登录失败
show_sql true 不报错,但看不到 SQL

第一行 dialect 最容易配错,也最容易忽略。 它决定 Hibernate 生成哪种方言的 SQL:分页用 ROWNUM 还是 LIMIT,日期函数怎么写。 配成 MySQLDialect 去连 Oracle,能连上,但每条 SQL 都报错。 这种错最坑:连接是通的,报错却发生在业务代码里。 最后一行 show_sql 是开发期必开、上线必关。 开着的代价不只是日志多——高并发时控制台输出本身会成为瓶颈。

二、映射文件:类名和表名怎么对上

一张表对一个类,一列对一个属性。 映射文件就是把这两组名字连起来的胶水。

列名 属性名 Java 类型 约束
emp_id empId long 主键
emp_no empNo String 唯一
emp_name empName String 非空
dept_id deptId long 外键
post post String —

这张表就是 .hbm.xml 的内容,一行一个 property 标签。 类名和表名的对应写在 <class name table> 上。 命名有个约定:数据库用下划线(emp_name),Java 用驼峰(empName)。 两者靠映射连起来,代码里就看不到下划线了。 这条约定的价值在于「不用记两套命名」。 代价是多一个文件要维护——表加一列,映射文件必须跟着加。 这一步最常见的错是「漏了一列」。 漏了不会报错,只会查出来是 null,然后在业务代码里变成一个空指针。

三、通用 CRUD:四条方法,一个模式

save/get/update/delete 四条方法,界面上所有操作都调它们。 写一次,三个模块共用。

class Dao:
    def __init__(self, c):
        self.c = c

    def save(self, d):
        self.c.execute("insert into tb_personnel values "
                       "(:empId,:empNo,:empName)", d)
        self.c.commit()

    def get(self, k):
        r = self.c.execute(
            "select emp_id, emp_no, emp_name from tb_personnel "
            "where emp_id=?", (k,)).fetchone()
        if r is None:
            return None
        return dict(zip(("empId", "empNo", "empName"), r))

    def delete(self, k):
        self.c.execute("delete from tb_personnel where emp_id=?", (k,))
        self.c.commit()

Demo 真跑了一遍:存进去、取回来、改一次、删掉。 四个字段都对得上,删完 get 返回 None。 要注意真实 Hibernate 里 get 返回的是对象,不是字段字典。 区别在「能不能直接调方法」—— 这里用字典模拟,语义是一样的,但少了一个「对象生命周期」的概念。 对象生命周期这个概念很重要: Hibernate 里一个对象有「临时、持久、游离」三种状态, 调完 save 它就从临时变持久,session 一关又变游离。 字典没有这个状态机,所以这一层模拟是不完整的。

四、事务边界:这一节比省代码重要

三条路可选,只有一条是对的。

放法 好处 问题
界面上(按钮事件里) 直观 一个业务要开好几个事务
DAO 里(每条方法一个) 简单 跨表的业务没法原子
Service 里(一次业务一个) 边界正确 要多写一层

正确的是第三行。理由用 19.3 那个「新增员工」看最清楚: 要写员工表和工资表两张表。 事务在 DAO 里,就是两个独立事务——第一步成功、第二步失败,数据就脏了。 事务在 Service 里,才是一个整体。 这一层加出来,代码多了一个文件。 但它换来的是「要么全成、要么全不成」,这是数据正确性的底线。 顺带说一句:事务边界也是【锁的范围】。 边界画得太大,锁持得久,并发就差;画得太小,原子性就没了。 这两者的取舍在 13 章讲锁时已经量过:粒度越细并发越好,代价是管理复杂。

五、部门树对话框:两个界面共用一棵树

教材的 19.6 里有三个界面件:部门树对话框、选员工面板、选员工对话框。 它们的共同点是都要显示同一棵部门树。 所以正确做法是:树只写一次,抽成一个组件,三处引用。 写三遍的后果是「改一处忘两处」——部门排序规则一改,三个地方的行为就不一致了。 这种「看起来是界面问题、实际是复用问题」的地方,是公共模块最该管的。 判断标准很简单:同一段逻辑在第二个地方出现,就该抽出来。 抽的时候要注意一个细节:树的数据从哪来。 如果每个对话框自己查一次数据库,就变成了三次查询。 正确做法是调用同一个 DAO 方法,让缓存或者上层统一管。 这也解释了为什么公共模块要放在业务模块之前做: 它是被引用的那一方,定得越晚,返工越多。

六、动手 Demo:解析配置、生成映射、跑通四条 CRUD

Demo 分三段:真解析一遍 XML 配置、按映射规则生成 .hbm.xml 片段、跑四条 CRUD。 三段都不依赖 Hibernate 本身,用标准库就能跑。 第一段看的是「配置里到底有什么」,第二段看的是「类和表怎么对上」, 第三段看的是「四条方法能不能真的改数据」。

import xml.etree.ElementTree as ET
CFG = """<hibernate-configuration><session-factory>
  <property name="dialect">org.hibernate.dialect.Oracle9Dialect</property>
  <property name="connection.url">jdbc:oracle:thin:@127.0.0.1:1521:orcl</property>
  <property name="show_sql">true</property>
</session-factory></hibernate-configuration>"""
for p in ET.fromstring(CFG).iter("property"):
    print("%-15s %s" % (p.get("name"), p.text))

MAP = [("emp_id", "empId", "long"), ("emp_no", "empNo", "String"),
       ("emp_name", "empName", "String")]
out = ['<class name="entity.Personnel" table="tb_personnel">']
for col, prop, typ in MAP:
    out.append('  <property name="%s" column="%s" type="%s"/>'
               % (prop, col, typ))
out.append("</class>")
print("\n".join(out))

实跑输出:

--------------------------------------------------------------------
第八十九课:公共模块——配置、映射、通用 CRUD
--------------------------------------------------------------------

  **公共模块是三件事:一份配置文件、一批映射规则、一组增删改查方法。**
  **三件事都能真跑:XML 真解析,映射真生成,CRUD 真执行。**

  一、hibernate.cfg.xml:真解析一遍
  --------------------------------------------------------------------

  配置项                  值
  -------------------------------------------------------------
  dialect                 org.hibernate.dialect.Oracle9Dialect
  connection.driver_class oracle.jdbc.driver.OracleDriver
  connection.url          jdbc:oracle:thin:@127.0.0.1:1521:orcl
  connection.username     scott
  connection.password     tiger
  show_sql                true
  已选择 6 行
  **四行是必须的:方言、驱动、URL、账号。**
  **少一行,启动时就报「找不到 dialect」。**
  **第一行 `dialect` 最容易配错。**
  **它决定 Hibernate 生成哪种 SQL 方言:分页用 `ROWNUM` 还是 `LIMIT`,日期函数怎么写。**
  **配成 `MySQLDialect` 连 Oracle,能连上,但每条 SQL 都报错。**
  **最后一行 `show_sql=true` 是开发期必开、上线必关。**
  **开着的代价是每条 SQL 都往控制台打,高并发时控制台输出本身就成了瓶颈。**
  **映射文件 3 个:Personnel.hbm.xml、Dept.hbm.xml、Wage.hbm.xml。**

  二、映射文件:真生成一个持久化类
  --------------------------------------------------------------------

  列名     属性名  JAVA 类型 约束
  -------------------------------
  emp_id   empId   long      主键
  emp_no   empNo   String    唯一
  emp_name empName String    非空
  dept_id  deptId  long      外键
  post     post    String
  已选择 5 行
  **这张表就是 `.hbm.xml` 的内容,一行一个 `<property>`。**
  **类名和表名的对应写在 `<class name="..." table="...">` 上。**
  **命名有个约定:数据库用下划线(`emp_name`),Java 用驼峰(`empName`)。**
  **两者靠映射连起来,代码里就看不到下划线了。**

  三、通用 CRUD:四条方法真跑
  --------------------------------------------------------------------
  **save 之后 get 回来,五个字段都在:**

  字段    值
  ---------------
  empId   1
  empNo   E000001
  empName 张三
  deptId  3
  post    工程师
  已选择 5 行
  **update 之后再 get:岗位 = 高级工程师**
  **delete 之后再 get:None**
  **四条方法一套,界面上所有「新增/修改/删除」都调这四个。**
  **这就是公共模块的价值:SQL 只写一遍。**
  **注意真实 Hibernate 里 `get` 返回的是对象,不是字段字典。**
  **区别在「能不能直接调方法」——这里用字典模拟,语义是一样的。**

  四、事务边界放在哪一层
  --------------------------------------------------------------------

  放法                          好处     问题
  -------------------------------------------------------------
  放界面上(按钮事件里)        直观     一个业务要开好几个事务
  放 DAO 里(每条方法一个)     简单     跨表的业务没法原子
  放 Service 里(一次业务一个) 边界正确 要多写一层
  已选择 3 行
  **正确的是第三行。**
  **理由用第 86 课的「新增员工」看最清楚:要写 `tb_personnel` 和 `tb_wage` 两张表。**
  **事务在 DAO 里,就是两个独立事务**——**第一步成功、第二步失败,数据就脏了。**
  **事务在 Service 里,才是一个整体。**
  **这一层加出来,代码多了一个文件。**
  **但它换来的是「要么全成、要么全不成」,这是数据正确性的底线。**

第一段输出三行配置项。 注意 dialect 的值是一个完整类名,不是一个短名—— 写 Oracle 是不认的,必须写全 org.hibernate.dialect.Oracle9Dialect。 第二段输出 5 行 <property>,一行一列。 这就是「一张表对一个类」在文件层面的样子。 要看清 name 和 column 两个属性:name 是 Java 属性名(驼峰), column 是数据库列名(下划线)。 这个对应关系一旦写错,查出来就是 null,不报错。 第三段是四条 CRUD 的实际输出。 save 之后 get 回来五个字段都对,update 之后岗位变了,delete 之后返回 None。 要注意 delete 之后 get 返回 None 而不是报错——这是「按主键查不到」的正常语义。 如果业务上要求「删了之后不能再查」,那要在 Service 层判空,不能在 DAO 层抛异常。 最后那张事务边界的表是三选一。 Demo 没有真跑事务,因为 SQLite 的单连接事务语义和 Hibernate 的 Session 不是一回事。 这一点必须说清:事务边界是【设计判断】,不是这一跑能证明的东西。

30 秒实验

  1. 把 CFG 里 dialect 的值改成 MySQLDialect,看解析会不会报错——不会, 因为 XML 解析不管语义,这也说明「配错方言启动才报」的原因。
  2. 往 MAP 里加一行 ("post", "post", "String"),看生成的 <property> 多几行, 再想想「漏一列」为什么不会报错。
  3. 把 delete 里的 commit() 去掉,看删掉之后 get 还查不查得到——这就是「事务没提交, 别人看不见」的最小演示。

常见误区

  1. 「公共模块就是工具类」。 工具类没有状态, 公共模块管的是「配置、映射、事务边界」这三件有状态的事。
  2. 「事务写在 DAO 里最简单」。 简单,但跨表的业务没法原子。 省下的是一个 Service 文件,赔上的是数据正确性。
  3. 「show_sql 一直开着方便排查」。 开发期可以, 上线必须关——高并发时控制台输出本身会变成瓶颈。
  4. 「映射文件漏一列没关系,反正查不出来是 null」。 null 会在业务代码里变成空指针,而报错位置离真正的原因很远。
自测三问(点开对答案) 1. `dialect` 配错了会怎样? —— **连接能通,但每条 SQL 都报错。** **因为它决定生成哪种方言:分页、日期函数、字符串拼接都不一样。** 2. 事务边界应该放在哪一层?为什么? —— **Service 层。** 一次业务可能写多张表, **只有把整次业务包在一个事务里,才能保证「要么全成、要么全不成」。** 3. `get` 查不到时应该返回什么? —— **返回空(`None` 或 `null`),不要抛异常。** **「查不到」是正常语义,是否当成错误由 Service 层判断。** 4. 为什么公共模块要先做? —— **因为它是被引用的那一方。** **三个业务模块都调它,定得越晚,前面写的代码返工越多。**

下一篇进第一个业务模块:人事管理。 核心是一个问题——点中一个部门,怎么把它下面所有人的查出来。

相关笔记