大多数人,都低估了编程学习的难度,而高估了自己的学习能力和毅力。
当前系列: JDBC&Hibernate 修改讲义

名称解释

为演示方便,再引入Score和Bed类,自此:

  • 多对多:Student和Teacher
    @ManyToMany(mappedBy = "students")
    private Set<Teacher> teachers = new HashSet<>();
    
    @ManyToMany
    private Set<Student> students = new HashSet<>();
  • 一对多:Student和Score
    @OneToMany(mappedBy = "candidate")
    private List<Score> scores  = new ArrayList<>();
    
    @ManyToOne
    private Student candidate;
  • 一对一:Student和Bed
    @OneToOne(mappedBy = "sleepedBy")
    private Bed bed;
    
    @OneToOne
    private Student sleepedBy;
均设置为双向,且Student为inverse端。
Student atai = new Student("atai", true, 17, DayOfWeek.MONDAY, LocalDate.of(2011, 5, 1),
		new Contact("atai@qq.com", "178654321"), "我是阿泰,泰山的泰,^_^");
Student bo = new Student("bo", true, 16, DayOfWeek.TUESDAY, LocalDate.of(2011, 6, 1),
		new Contact("bo@qq.com", "158654322"), "我是波仔,鱼仔的仔");
Student lang = new Student("lang", false, 21, DayOfWeek.FRIDAY, LocalDate.of(2011, 5, 18),
		new Contact("lang@qq.com", "148654323"), "我是浪神,女神的神……");

Teacher fg = new Teacher("fg");
Teacher xy = new Teacher("xy");

Score aSQL = new Score("SQL",atai, 85.0f);
Score aJava = new Score("Java",atai, 90.0f);
Score bSQL = new Score("SQL",bo, 85.0f);
Score bJava = new Score("Java",bo, 78.5f);
Score bJavascript = new Score("JavaScript",bo, 82.5f);
Score lSQL = new Score("SQL",lang, 78.0f);
Score lCSharp = new Score("CSharp",lang, 86.0f);
Score lJava = new Score("Java",lang, 55.5f);		

Bed aBed = new Bed("闭包间", atai); Bed lBed = new Bed("二叉树", lang);

fg.getStudents().add(atai);
fg.getStudents().add(bo);

xy.getStudents().add(bo);
xy.getStudents().add(lang);
em.persist(fg);	
em.persist(xy); em.persist(bo);
em.persist(atai);
em.persist(lang);

em.persist(aBed);
em.persist(lBed);
em.persist(aSQL);
em.persist(aJava);
em.persist(bSQL);
em.persist(bJava);
em.persist(bJavascript);
em.persist(lJava); 
em.persist(lCSharp);
em.persist(lSQL);


加载

默认的,当我们加载一个entity的时候,除了集合以外,它的其他

@想一想@:关联属性怎么及时加载?log演示:使用JOIN

Student atai = em.find(Student.class, 1);   //SELECT ???

//基本属性
System.out.println(atai.getAge());
System.out.println(atai.getName());
System.out.println(atai.getEnroll());

//ValueType
Contact contact = atai.getContact();
System.out.println(contact.getEmail());

//关联属性
Bed bed = atai.getBed();  
System.out.println(bed.getId());   
System.out.println(bed.getName());

//集合:以下那一句语句执行时会查询数据库?
System.out.println(atai.getTeacher() == null);
Set<Teacher> teachers = atai.getTeacher();
System.out.println(teachers.size());
for (Teacher teacher : teachers) {
	System.out.println(teacher.getName());
}

但关联属性和集合的加载方式可以通过在注解中设置属性值fetch予以改变:

@ManyToMany(mappedBy = "students", fetch = FetchType.EAGER)

log演示:SELECT中主动的JOIN

但是,当出现一对一的双向链接时,如果我们是从inverse的一段设置到owner的关联lazyload

@OneToOne(mappedBy = "sleepedBy", fetch = FetchType.LAZY)
private Bed bed;
我们的自定义就会失效。

log演示:首次加载就出现两次SELECT,第一次SELECT不涉及bed,第二次就JOIN Bed……

@想一想@:为什么呢?

因为lazyload需要Id!Student表里没有Bed的Id,Hibernate无法构建proxy对象。

演示:从Bed到Student就可以lazyload。


添加

已有两个entity,我们要在其间添加关联。

注意:如之前演示,双向引用一定要在owner一端添加(实际开发中就总是在两端添加即可)

一对一

直接进行设置就可以了

Student lang = em.find(Student.class, 3);
Bed bed = em.find(Bed.class, 2);
			
//合乎双向关联规则的(可省略)
lang.setBed(bed);
//起决定作用的
bed.setSleepedBy(lang);

本质上是一条UPDATE语句,但我们却额外执行了两条SELECT语句,@想一想@:是不是不值?

这里使用session.load()或em.getReference()也没有用,因为:所有的lazyload,在使用setter的时候必然查询数据库(演示:略)

但是,如果省略掉lang.setBed(bed);可以少一个有关lang的SELECT语句。

多对多

Teacher fg = em.getReference(Teacher.class, 1);
Student lang = em.getReference(Student.class, 3);
//会通过SELECT...JOIN加载出fg的students集合
fg.getStudents().add(lang);		
//一样会SELECT...JOIN,但作为inverse端,可省略
lang.getTeachers().add(fg);

一样出现了没有必要的SELECT。

而且在加载students的时候,会依次查询Student的Bed(因为关联属性引用)

@想一想@:如果是重复的添加,会有什么样的结果?

List和Set

当使用Hibernate的时候,集合用List还是Set,某些时候会对SQL的生成产生影响。比如我们将teachers和students都改成List:

@ManyToMany
private List<Student> students = new ArrayList<>();

@ManyToMany(mappedBy = "students")
private List<Teacher> teachers = new ArrayList<>();

首先,关系表不再有主键了(演示)

然后,当我们添加一个关联时,神奇的事情发生了:

delete 
from
            Teacher_Student 
where
            teachers_id=?

Hibernate会先将之前的关联全部删除,然后再重新写入!为什么会这样?因为这是List,List中没有为每个元素本身进行“标记”,所以集合元素会被各种魔改:删了又加,加了又改,……,最后flush的时候,Hibernate将此时的集合和最初加载时的snapshot比较,已经无能为力(非常麻烦)了,比如这样:


@想一想@:你会怎么比对这两个List集合,确定相应的增删改?注意:你一定是要比较元素本身(而不是下标)的,这种比较有可能是极其耗时的。

但Set不一样,因为它是有键值确保元素唯一的,它就可以用唯一键方便快速比对。

推荐:条件允许的话,还是使用Set吧。

一对多

实际开发中通常都是在生成一(端entity)的同时就建立了一对多的关联并持久化。以下仅为演示用:

先准备一个“无主的”Score:

Score lHtml = new Score();
lHtml.setName("HTML");
em.persist(lHtml);
em.flush();
再添加关联关系:
Student lang = em.getReference(Student.class, 3);
//因为setter,会SELECT...FROM Student
lHtml.setCandidate(lang);
//这里“不”会额外SELECT哟,赞!
//inverse端,可省略
lang.getScores().add(lHtml);
//生成UPDATE语句
em.flush();

@想一想@:为什么一对多时可用不用SELECT集合,多对多就必须要SELECT?


性能优化考虑

如果从性能优化的角度考虑,要慎用(不是说不能用):

一对一的关联

首先考虑是不是可以用ValueType代替,因为ValueType的加载不需要外键JOIN。

然后考虑是不是可以使用一对多替换,这倒无关性能,而是为未来扩展提供可能性。比如现在一个学生一张床,但以后呢?源栈同学出栈入栈,一定会形成一(张床)对多(个学生)的关系。

实际上,必须要用一对一关联,而不能使用ValueType或一对多关系的例子是非常非常少的。

双向引用

首先,要搞清楚owner端和inverse端非常麻烦(尤其是没有annotation,而是xml配置的时候);

不想搞清楚,蒙着头写,就得两端同步,造成性能上的损耗。

尤其是一对一的双向引用:默认采用eager load模式,所以inverse的一端始终无法proxy load,就会JOIN查询,非常没有必要。

集合

虽然有lazyload,但一旦使用到getter/setter,也非常容易触发SELECT:
  • 首先是数据量大:会把所有数据不加过滤的全部加载。
  • 在某些场景下,修改集合元素还会出现“重建”(即DELETE所有之后再ADD)

所以每当你要引入集合的时候,仔细思考:有无必要?是不是entity不可或缺的一部分?比如:汽车的轮胎,就是不可或缺,缺了汽车没法跑;老师的学生就不一定是不可或缺的,没了学生的老师还可以是老师。具体到代码层面,就是没有这个集合,我这个entity就没办法实现某个功能,那这个集合就是必须的,比如老师有一个点名的功能……

你清楚的知道这样做会带来的问题,仔细权衡之后你仍然觉得这样做,那一般来说都是OK的。

拆分多对多

引入一个关系表对应的entity:

@Entity
public class Student2Teacher implements Serializable {
	@Id
	@ManyToOne
	private Student student;
	@Id
	@ManyToOne
	private Teacher teacher;
说明:
  • Hibernate要求必须有@Id(PrimaryKey),
  • 在多个列上标记@Id会生成联合主键
  • 组合@Id必须实现Serializable

Student和Teacher分别反向(inverse)引用这个关系entity:

@OneToMany(mappedBy = "student")
private Set<Student2Teacher> teachers = new HashSet<>();
@OneToMany(mappedBy = "teacher")
private Set<Student2Teacher> students = new HashSet<>();

添加关联时之前的:

fg.getStudent().add(atai);
就要变成:
em.persist(new Student2Teacher(atai, fg));
于是我们就可以独立的操作这个关系entity(比如INSERT)

log演示:仅仅在flush()时生成一条INSERT语句


更改和删除

以下不深究SELECT相关的性能,只关注:

  • 如何用面向对象的方法表示
  • 数据库上实际发生的变化

单个元素

直接更改关联属性为新的entity或者NULL(删除)即可。例如:
  • 一对一:学生lang(id=3)的床变成atai(id=1)的
    Student lang = em.find(Student.class, 3);
    //注意owner和inverse端的区别
    lang.getBed().setSleepedBy(em.find(Student.class, 1));;	
    update Bed set name=?, sleepedBy_id=? where id=?
    binding parameter [2] as [INTEGER] - 1
    
  • 一对多:把某成绩从属于atai的改为不再属于atai
    Score score = em.find(Score.class, 1);
    score.setCandidate(null);	
    update Score set name=?, candidate_id=?, point=? where id=?
    binding parameter [2] as [INTEGER] - [null]

集合:一对多

从面向对象的角度,集合是可以替换的,比如要交换atai和lang的成绩:

Student atai = em.find(Student.class, 5);
Student lang = em.find(Student.class, 6);
atai.setScores(lang.getScores());    //行不行?

注意:这样做的结果是让atai和lang“共享”一个Scores集合,而是lang的Scores给atai,lang自己还有一个副本啥的……(复习:值类型和引用类型

而且,在我们的映射关系中,一对多的集合端,是inverse的一段不是owner一段,在上面的操作是不会对数据库生效的!

PS:基于同样的原因,如果ORM严格遵守JPA规范,add()/remove()等都不会生效。

所以我们还是得在集合中迭代:

//lang的成绩挨个重新设置考生atai
for (Score score : lang.getScores()) {
	score.setCandidate(atai);
}
//再把atai的成绩挨个设置考生为lang
atai.getScores().stream().forEach(s->s.setCandidate(lang));	

@想一想@:第一个foreach结束之后,atai.getScore()会不会在原基础上增加?为什么?

集合:多对多

这时候有一个集合在owner端,就可以对数据库产生实质性的影响了。

但基于和“一对一”一样的原因,交换我们就省略了。我们演示:

  • 替换。让xy老师的学生从bo和atai变成atai和lang,这样行不行:
    //xy老师本来的学生是4和6
    Teacher xy = em.find(Teacher.class, 4);
    //想改成5和6
    HashSet<Student> newStudents = new HashSet<>();
    newStudents.add(em.find(Student.class, 5));
    newStudents.add(em.find(Student.class, 6));
    xy.setStudents(newStudents);
    演示:Hibernate先删除(DELETE)再添加(INSERT)
  • 删除某一个关联
    Student atai = em.find(Student.class, 1);
    Teacher fg = em.find(Teacher.class, 1);
    fg.getStudents().remove(atai);
    理解:atai不再是fg的学生,fg不再是atai的老师,不能说atai就没了……


级联cascade

在Hibernate里面,也可以通过annotation进行设置。

保存

比如给某个床位设定一个Student:

em.find(Bed.class, 1).setSleepedBy(
		new Student());

如果没有cascade:

Caused by: org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing : Bed.sleepedBy -> Student

意思是说:new出来的student还是transient的,不能直接flush。要是不用cascade,代码得这样写:

Student student = new Student();
em.persist(student);
em.find(Bed.class, 3).setSleepedBy(student);

显得比较麻烦,所有可以直接在Bed中设置:

@OneToOne(cascade = CascadeType.PERSIST) 
private Student sleepedBy;

演示:生成INSERT Student语句

删除

演示:删除一个学生呢?

Student lang = em.find(Student.class, 1);			
em.remove(lang);

报错:(注意:如果接着上面的代码演示,没有删掉sleepedBy上的cascade就完全不一样了)

Caused by: com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Cannot delete or update a parent row: a foreign key constraint fails (`17bang`.`teacher_student`, CONSTRAINT `FK_Teacher_Student_students_id_Student` FOREIGN KEY (`students_id`) REFERENCES `student` (`id`)

理解:cascade本质上还是以外键约束为基础的(复习:SQL中自带的cascade功能设置)。但Hibernate喜欢使用这种表述:删除父类对象的时候,如何处理子类对象……

@想一想@:删除Bed和Score呢?会不会造成cascade?

所以需要设置:

@ManyToMany(mappedBy = "students", cascade = CascadeType.REMOVE)
private Set<Teacher> teachers = new HashSet<>();

演示:还需要继续设置所有相关联的Bed和Score……以及灾难性的结果:

该删的(Bed和Score)不该删的(Teacher)都删了。

多对多不存在cascade

演示:没有设置cascade的时候,删除一个老师

Teacher fg = em.find(Teacher.class, 3);			
em.remove(fg);

可以看到,Hibernate会自动在删除老师的同时(之前),删除掉该Teacher和Student的关联。

如果我们加上CascadeType.REMOVE,他就会cascade到删除学生的entity!

log演示SQL:    delete from Student ……

PS:还有其他的CascadeType,用得不多:略

怎么办?

首先,不能在@ManyToMany上设置cascade;

然后,通过Student找到他的所有Teacher,在Teacher端删除和这个Student的关联:

lang.getTeachers().forEach(
		t->t.getStudents().removeIf(
				s->s.getId() == lang.getId()));	
log演示:delete  from Teacher_Student


orphanRemoval

标记当一个entity成为孤儿时自动删除,在JPA 2.0中引入,可以通过annotation进行标记:

@OneToOne(mappedBy = "sleepedBy", orphanRemoval = true)
private Bed bed;

演示:从运行效果上来看,它和CascadeType.REMOVE非常类似。

但是,两者的差别非常非常微妙。StackOverflow高赞回答说……

然鹅,还是要cacade才会触发orphanRemoval。

演示:直接把Bed的SleepBy设置成NULL看看有没有用?

飞哥能想到的例子:在一对多的关系中,删除集合中某一个元素

Student lang = em.find(Student.class, 6);	
//本来,Student作为inverse一端,对其集合的操作是无法持久化到数据库的
lang.getScores().remove(0);

但如果我们进行如下设置:

@OneToMany(mappedBy = "candidate", 
		//两个都必须要有
		cascade = CascadeType.PERSIST , orphanRemoval = true )
private List<Score> scores = new ArrayList<>();

就能够实现删除第一条成绩的效果。log演示:DELETE语句

为什么呢?其(生硬的)逻辑如下:

  1. Student的Scores集合发生了改变(其中一条被删除了),产生了级联效应传导到Score,表现为该Score的candidate应该为NULL
  2. 而我们设定的处理方式是PERSIST,也就是要对级联效应进行保存
  3. 最后flush()的时候,检查发现某Score成为了孤儿,于是orphanRemoval设定生效,将该条Score删除。
也就是说,orphanRemoval绕过了“inverse端不持久化”的限制直接发挥作用。


再次总结

这一章节的内容,非常容易被滥用。

稍有不慎,它就会:

  • 偷走你的性能:双向引用和集合额外的SELECT
  • 悄悄删除一些不应该删除的数据:@ManyToMany的CascadeType.REMOVE
  • 或者留下垃圾数据:orphan

同学们在使用的时候,务必小心小心再小心。


作业

完成ORM介绍中第5、6、7题

学习笔记
源栈学历
今天学习不努力,明天努力找工作

作业

觉得很 ,不要忘记分享哟!

任何问题,都可以直接加 QQ群:273534701

在当前系列 JDBC&Hibernate 中继续学习:

多快好省!前端后端,线上线下,名师精讲

  • 先学习,后付费;
  • 不满意,不要钱。
  • 编程培训班,我就选源栈

更多了解 加:

QQ群:273534701

答疑解惑,远程debug……

B站 源栈-小九 的直播间

写代码要保持微笑 (๑•̀ㅂ•́)و✧

公众号:源栈一起帮

二维码