
引言
几乎任何信息系统都以一种或另一种方式与外部数据存储进行交互。 在大多数情况下,这是一个关系数据库,并且通常使用某种ORM框架来处理数据。 ORM消除了大多数常规操作,而是提供了一小组用于处理数据的附加抽象。
Martin Fowler发表了一篇有趣的文章 ,这是其中的关键思想之一:“ ORM可以帮助我们解决企业应用程序中的大量问题……此工具虽然不能称为漂亮工具,但是它所处理的问题也不是很好。 我认为ORM应该得到更多的尊重和更多的理解。”
我们在CUBA框架中非常频繁地使用ORM,因此,由于CUBA已在世界各地的各个项目中使用,因此我们第一手知道了该技术的问题和局限性。 关于ORM,可以讨论许多主题,但我们将重点关注其中之一:在“懒惰”(懒惰)和“贪婪”(急切)数据采样方法之间进行选择。 我们将通过JPA API和Spring的插图来讨论解决此问题的不同方法,并描述CUBA如何(以及为什么要精确地)使用ORM,以及我们正在做哪些工作来改进框架中数据的工作。
数据采样:懒还是不?
如果您的数据模型只有一个实体,那么在使用ORM时您很可能不会注意到任何问题。 让我们看一个小例子。 假设我们有一个User ()
实体,它具有两个属性: ID
和Name ()
:
public class User { @Id @GeneratedValue private int id; private String name;
要从数据库中获取此实体的实例,我们只需要调用EntityManager
对象的一种方法:
EntityManager em = entityManagerFactory.createEntityManager(); User user = em.find(User.class, id);
一对多关系出现时,事情会变得更加有趣:
public class User { @Id @GeneratedValue private int id; private String name; @OneToMany private List<Address> addresses;
如果我们需要从数据库中提取用户实例,则会出现问题:“我们还选择地址吗?”。 此处的“正确”答案是:“取决于...”在某些情况下,我们将需要地址,而在某些情况下-则不是。 通常,ORM提供了两种获取依赖记录的方式:懒惰和贪婪。 默认情况下,大多数ORM使用惰性方式。 但是,如果我们编写这段代码:
EntityManager em = entityManagerFactory.createEntityManager(); User user = em.find(User.class, 1); em.close(); System.out.println(user.getAddresses().get(0));
...然后我们得到“LazyInitException”
异常,这使刚开始使用ORM的新手感到非常困惑。 当您需要启动有关实体的“附加”和“独立”实例是什么,会话和事务是什么的故事时,就来了。
是的,这意味着必须将实体“附加”到会话,以便您可以选择相关数据。 好吧,我们不要立即关闭交易,生活会立即变得容易。 这里又出现了另一个问题-事务变得更长,这增加了死锁的风险。 缩短交易时间? 有可能,但是如果您创建许多很多小交易,我们将获得“ Komar Komarovich的故事-长长的鼻子,毛茸茸的Misha-短尾巴”,有关成群的小熊蚊子如何获胜-这将在数据库中发生。 如果小事务的数量显着增加,则会出现性能问题。
如前所述,在获取有关用户的数据时,可能不需要地址,因此,根据业务逻辑,您需要选择是否收集。 有必要在代码中添加新条件……嗯……某种程度上使事情变得复杂。
那么,如果您尝试其他类型的样本该怎么办?
public class User { @Id @GeneratedValue private int id; private String name; @OneToMany(fetch = FetchType.EAGER) private List<Address> addresses;
好吧...您不能说这会有所帮助。 是的,我们将摆脱讨厌的LazyInit
并且无需检查实体是否已连接到会话。 但是现在我们可能会遇到性能问题,因为我们并不总是需要地址,而是仍在服务器内存中选择这些对象。
还有其他想法吗?
春天的JDBC
一些开发人员对ORM感到厌倦,以至于他们转向其他框架。 例如,在Spring JDBC上,它提供了以“半自动”模式将关系数据转换为对象数据的功能。 开发人员针对需要特定属性集的每种情况编写查询(或者对于需要相同数据结构的情况重用相同的代码)。
这给了我们极大的灵活性。 例如,您只能选择一个属性而不创建相应的实体对象:
String name = this.jdbcTemplate.queryForObject( "select name from t_user where id = ?", new Object[]{1L}, String.class);
或以通常的形式选择一个对象:
User user = this.jdbcTemplate.queryForObject( "select id, name from t_user where id = ?", new Object[]{1L}, new RowMapper<User>() { public User mapRow(ResultSet rs, int rowNum) throws SQLException { User user = new User(); user.setName(rs.getString("name")); user.setId(rs.getInt("id")); return user; } });
您还可以为用户选择一个地址列表,您只需要编写一些代码并正确编写SQL查询即可避免出现n + 1个查询的问题 。
太糟糕了,又复杂了。 是的,我们控制所有查询以及如何将数据映射到对象上,但是我们需要编写更多代码,学习SQL并知道如何在数据库中执行查询。 就我个人而言,我认为SQL知识是应用程序程序员必需的技能,但并不是每个人都这么认为,而且我也不会参与辩论。 毕竟,这几天对x86汇编指令的了解也是可选的。 让我们更好地考虑如何使程序员的生活更轻松。
JPA实体图
让我们退后一步思考,我们需要什么? 似乎我们只需要指出每种情况下需要的属性即可。 好吧,让我们做吧! JPA 2.1引入了新的API-EntityGraph(实体图)。 这个想法很简单:我们使用注释来描述我们将从数据库中选择什么。 这是一个例子:
@Entity @NamedEntityGraphs({ @NamedEntityGraph(name = "user-only-entity-graph"), @NamedEntityGraph(name = "user-addresses-entity-graph", attributeNodes = {@NamedAttributeNode("addresses")}) }) public class User { @Id @GeneratedValue private int id; private String name; @OneToMany(fetch = FetchType.LAZY) private Set<Address> addresses;
为该实体描述了两个图: user-only-entity-graph
不选择Addresses
属性(标记为lazy),而第二个图告诉ORM选择该属性。 如果我们将“ Addresses
标记为“渴望”,则该图将被忽略,并且无论如何都将选择地址。
因此,在JPA 2.1中,您可以像这样采样数据:
EntityManager em = entityManagerFactory.createEntityManager(); EntityGraph graph = em.getEntityGraph("user-addresses-entity-graph"); Map<String, Object> properties = Map.of("javax.persistence.fetchgraph", graph); User user = em.find(User.class, 1, properties); em.close();
这种方法大大简化了工作,无需分别考虑惰性属性和事务长度。 另一个好处是,该图在SQL查询级别应用,因此在Java应用程序中未选择“额外”数据。 但是有一个小问题:您不能说选择了哪些属性,哪些没有选择。 有一个用于检查的API,这是使用PersistenceUtil
类完成的:
PersistenceUtil pu = entityManagerFactory.getPersistenceUnitUtil(); System.out.println("User.addresses loaded: " + pu.isLoaded(user, "addresses"));
但这是相当乏味的,并不是每个人都准备好进行此类检查。 您还有什么可以简化的,而不只是显示未选择的属性?
春季预测
Spring框架有一个很棒的东西叫做Projections (这与Hibernate中的投影不同 )。 如果您只需要选择实体的某些属性,则会创建一个具有必要属性的接口,然后Spring从数据库中选择该接口的“实例”。 例如,考虑以下接口:
interface NamesOnly { String getName(); }
现在,您可以定义一个用于获取用户实体的Spring JPA存储库,如下所示:
interface UserRepository extends CrudRepository<User, Integer> { Collection<NamesOnly> findByName(String lastname); }
在这种情况下,在调用findByName方法之后,在结果列表中,我们将获得只能访问接口中定义的属性的实体! 根据同一原则,人们可以选择从属实体,即 立即选择“主从关系”。 而且,在大多数情况下,Spring都会生成“正确的” SQL,即 仅从数据库中选择投影中描述的那些属性,这与实体图的工作方式非常相似。
这是一个非常强大的API,在定义接口时,您可以使用SpEL表达式,可以使用具有某种内置逻辑的类(而不是接口),并且在文档中详细介绍了所有内容 。
预测的唯一问题是在内部将其实现为键-值对,即 是只读的。 这意味着,即使我们为投影定义了setter方法,我们也将无法通过CRUD存储库或EntityManager保存更改。 因此,投影是DTO,仅当您为此编写自己的代码时,它们才能转换回Entity并保存。
如何在CUBA中选择数据
从CUBA框架开发的最开始,我们就尝试优化与数据库一起使用的代码部分。 在CUBA,我们使用EclipseLink作为数据访问API的基础。 EclipseLink的优点在于它从一开始就支持部分实体加载,这是在它和Hibernate之间进行选择的决定性因素。 在EclipseLink中,您可以指定要在JPA 2.1标准出现之前加载的属性。 CUBA有其描述实体图的方式,称为CUBA Views 。 制图表达CUBA是一个相当发达的API,您可以从其他继承一些制图表达,将它们组合起来,同时应用于主实体和详细实体。 创建CUBA视图的另一个动机是,我们希望使用短交易,以便我们可以在Web用户界面中使用分离的实体。
在CUBA中,视图在XML文件中描述,如下例所示:
<view class="com.sample.User" extends="_minimal" name="user-minimal-view"> <property name="name"/> <property name="addresses" view="address-street-only-view"/> </property> </view>
该视图选择User
实体及其本地属性name
,并通过将address-street-only-view
应用于地址来选择地址。 所有这些都发生(注意!)在SQL查询级别。 创建视图后,可以使用DataManager类在数据选择中使用它:
List<User> users = dataManager.load(User.class).view("user-edit-view").list();
这种方法很好用,同时经济地消耗了网络流量,因为未使用的属性不会简单地从数据库传输到应用程序,但是,就像在JPA的情况下一样,存在一个问题:无法说说已加载实体的哪些属性。 在CUBA中,有一个异常“IllegalStateException: Cannot get unfetched attribute [...] from detached object”
,就像LazyInit
一样,使用我们的框架编写的每个人都必须遇到该异常。 就像在JPA中一样,有多种方法可以检查加载了哪些属性,哪些没有,但是再次编写此类检查是一项繁琐而艰巨的任务,这会使开发人员感到非常沮丧。 需要发明一些其他东西,以免使人们负担理论上机器可以完成的工作。
概念-CUBA视图界面
但是,如果您尝试合并实体图和投影怎么办? 我们决定尝试这种方法,并为遵循Spring投影方法的实体视图接口开发了接口。 这些接口在应用程序启动时转换为CUBA视图,并可以在DataManager中使用。 这个想法很简单:我们描述一个接口(或一组接口),它是一个实体图。
interface UserMinimalView extends BaseEntityView<User, Integer> { String getName(); void setName(String val); List<AddressStreetOnly> getAddresses(); interface AddressStreetOnly extends BaseEntityView<Address, Integer> { String getStreet(); void setStreet(String street); } }
值得注意的是,在某些特定情况下,您可以创建本地接口,例如上例中的AddressStreetOnly
那样,以免“污染”应用程序的公共API。
在启动CUBA应用程序的过程中(其中大多数是初始化Spring上下文),我们以编程方式创建CUBA视图,并将它们放在上下文中的内部bean存储库中。
现在,您需要稍微修改DataManager类的实现,以便它接受接口视图,并且可以通过以下方式选择实体:
List<UserMinimalView> users = dataManager.load(UserMinimalView.class).list();
在幕后,将生成一个代理对象,该对象实现接口并包装从数据库中选择的实体实例(与Hibernate中的方式几乎相同)。 而且,当开发人员要求属性值时,代理将方法调用委托给实体的“实际”实例。
在发展这一概念时,我们试图用一块石头杀死两只鸟:
- 界面中未描述的数据不会加载到应用程序中,从而节省了服务器资源。
- 开发人员只能使用那些可以通过接口访问的属性(因此是从数据库中选择的),从而消除了上面我们所写的
UnfetchedAttribute
异常。
与Spring投影不同,我们将实体包装在代理对象中,此外,每个接口都继承了标准CUBA接口Entity
。 这意味着可以更改实体视图属性,然后使用标准CUBA API将这些更改保存到数据库中以处理数据。
顺便说一下,“第三只兔子”-如果仅使用getter方法定义接口,则可以使属性为只读。 因此,我们已经在实体API级别设置了修改规则。
此外,您可以使用可用属性(例如,名称字符串转换)对分离的实体执行一些本地操作,如下例所示:
@MetaProperty default String getNameLowercase() { return getName().toLowerCase(); }
注意,可以从实体类模型中取出计算出的属性,并将其传输到适用于特定业务逻辑的接口。
另一个有趣的功能是接口继承。 您可以使用不同的属性集制作多个视图,然后将它们组合。 例如,您可以为用户实体创建一个具有名称和电子邮件属性的接口,以及另一个具有名称和地址属性的接口。 现在,如果您需要选择姓名,电子邮件和地址,则无需将这些属性复制到第三个界面,只需从前两个视图继承即可。 是的,可以将第三个接口的实例传递给接受具有父接口类型的参数的方法,每个人的OOP规则都相同。
还实现了视图之间的转换-每个接口都有一个reload()方法,您可以将视图类作为参数传递给该方法:
UserFullView userFull = userMinimal.reload(UserFullView.class);
UserFullView可能包含其他属性,因此,如有必要,将从数据库中重新加载该实体。 并且该过程被延迟。 只有在第一次访问实体属性时才进行对数据库的访问。 这会稍微降低第一次调用的速度,但是这种方法是有意选择的-如果实体实例用于包含UI和自己的REST控制器的“ Web”模块中,则可以将该模块部署在单独的服务器上。 这意味着实体的强制过载将创建额外的网络流量-访问核心模块,然后访问数据库。 因此,将重载推迟到必要的时候,我们可以节省流量并减少数据库查询的数量。
该概念被设计为CUBA的模块,可以从GitHub下载使用示例。
结论
似乎在不久的将来,仅因为我们需要将关系数据转换为对象的工具,我们仍将在企业应用程序中大量使用ORM。 当然,将为复杂,独特的超高负载应用程序开发特定的解决方案,但是看来ORM框架将与关系数据库一样长寿。
在CUBA中,我们尝试最大程度地简化ORM的工作,在将来的版本中,我们将引入用于处理数据的新功能。 很难说这些将是表示接口还是其他接口,但是我确信一件事:在框架的未来版本中,我们将继续简化数据处理工作。