escapar CodingDaydreaming
Web应用一把梭:CRUD后篇
Spring Boot, Hibernate
# disclaimer: 这篇文章的质量仍有待提升 Hibernate的复杂被人诟病,但我认为,复杂不构成弃用的理由,因为复杂的工具有简单的用法,而反过来并不行。即使ORM生成了慢查询,也很难保证人都写好的查询。最优的方式是开慢查询Log,然后手动优化不够好的SQL。 在这篇文章里,我就谈谈这个“简单的用法”。 ## 需求 一个简单的Blog系统,可以记录每次保存的版本。 Topic:主题 TopicDetail:主题的版本 Tag:标签 Category:栏目 Topic 和 Tag 多对多 Topic 和 TopicDetail 一对多 Topic 和 Category 多对一 ## 配置对象之间的关系 ORM最重要的职责之一,就是要让干巴巴的数据库表映射成具有逻辑关系的面向对象的类,这样才方便写业务逻辑。处理人际关系很困难,处理猫人关系很困难,处理对象之间的关系也不轻松。 ### ~~划分聚合~~ ~~划分聚合可能对程序的运行不会有太大的影响,但对形成可维护的业务逻辑大有帮助。划分聚合的标准在这里不详说,可以参考[这篇文章](http://www.cnblogs.com/powerzhang/archive/2013/05/25/3099112.html)。聚合设计的原则是:聚合内强一致性,聚合之间最终一致性。聚合必须有一个聚合根。这个需求里有三个聚合(aggregate),Topic聚合、Tag聚合和Category聚合。~~ ### 实体类之间,关系配置在哪一方? #### 为什么要讨论这个问题? 两边都配不就好了吗?! 如果Topic和Category是多对一的关系,那么在Category里写List<Topic>,在Topic里写Category(这个方式是Unidirectional),不就表达地完美了吗? 问题是,查询操作方便一点,更新操作繁琐很多。如果要更新一篇文章,就必须**从两端同时维护**这个操作。先查出Topic里对应的Category持久化的对象,给Topic设定Category,**存一次**,再在查出来的Category里加上保存好的Topic,**再存一次**。这个方式可以用,但(对我来说)是反直觉的。如果操作一多,就会显得繁琐,还容易漏写错写。 更新操作上,最理想的方式是,**拿到这个Topic,直接存一下**,关系就都有了。换言之,**尽可能避免配双向的关系**,并**规定怎么配单向关系**。 查询操作上,我们可以用Repository指定查询,并不像维护两端的状态一样,那么费劲。 #### 那么怎么配? ~~根据我的习惯,在聚合内,关系配在聚合根。在聚合间,关系一般配在多方。~~ 这个说法也不甚严谨,我们直接举例。 我们不可能在栏目的编辑页里直接添加和修改文章,栏目应该作为一个属性,出现在文章的修改页面里(比如在下拉框里面选择)。所以,Category和Topic的关系配在Topic一方。如果需要根据Category找到Topic,那么可以用Repository另外指定查询。 想要编辑TopicDetail,必须知道Topic的信息,不然就没法将它作为一个新版本关联到Topic上。Topic不是TopicDetail的一个属性,我们不可能去创建一个TopicDetail,然后在下拉框里挑选它关联的Topic。我们一般会打开一个Topic修改页,并为它创建新的TopicDetail。所以Topic是主宰关系的一方。 如果仍然觉得难以理解,那就多试试、多写写吧。 ## 实现 ``` java @Getter @Setter @Accessors(chain = true) @Entity @Table(name="topic") @NamedQuery(name="Topic.findAll", query="SELECT topic FROM Topic topic") public class Topic{ @Id @GeneratedValue(strategy= GenerationType.IDENTITY) private long id; @Column(name="name") private String name; @Column(name="created_at") private ZonedDateTime createdAt; @ManyToOne @JoinColumn(name="category_id") private Category category; @ManyToOne @JoinColumn(name="latest_topic_detail_id") private TopicDetail latest; @OneToMany(cascade = CascadeType.ALL , orphanRemoval = true) @JoinColumn(name="topic_id") private List<TopicDetail> versions; @ManyToMany(cascade = CascadeType.ALL) @JoinTable(name="topic_tag", joinColumns={@JoinColumn(name="topic_id", referencedColumnName="id")}, inverseJoinColumns={@JoinColumn(name="tag_id", referencedColumnName="id")}) private List<Tag> tags; public void setVersions(List<TopicDetail> versions) { if(this.getVersions()!=null) { this.getVersions().clear(); }else{ this.versions = new ArrayList<>(); } if(versions!=null) { this.getVersions().addAll(versions); } } } ``` 在这个Topic里,我实现了一对多、多对一、多对多三种配置的方式。在其它的几个Domain Model里,都**没有关于Topic**的信息。 需要注意的点有: - CascadeType.ALL ,如果是关系的绝对主宰,很适合用ALL形式的级联。如果有别的需求,需要另外配置。**级联的各种方式和场景需要抽空理解一下**。 - orphanRemoval 是将集合的保存操作变得不反直觉。如果不开这个属性,保存时,我们需要将Collection里的数据都先清除、保存,然后再设定为当前的状态、保存,这样才可以保证保存的集合里放着正确数目的对象。详情见[这篇文章](http://blog.csdn.net/lzwglory/article/details/45313331)。 - setVersions的方法千万不能漏,也千万不要省略,不然会产生更新问题。 - 如果tags的多对多的三表关联关系让人感觉不靠谱,或者无法满足需求,不如把它拆成三个对象之间的关系。 - ZonedDateTime是官方方案,代替了joda.time的DateTime和糟糕的原Java Date API,JS的Date()对象编译成JSON文本后,可以被直接反序列化成ZonedDateTime。 ## 结论 可以下载源码并运行Swagger测试,会得到理想的更新和查询结果。 有空的话,我会补一下效果。 这个配置的方式可以应付大部分需求,但如果有特殊的需要,也要灵活变通。