Spring Data JPA:带文件

问候,这是有关Spring Data JPA的第二篇文章。 第一部分完全专注于水下耙,以及经验丰富的耙的技巧。 在这一部分中,我们将讨论如何根据您的需求优化框架。 此处描述的所有示例均可用。


计数


让我们从一个简单而同时又常见的任务开始:加载实体时,有必要有选择地下载其“女儿”。 考虑一个简单的例子:


@Entity public class Child { @Id private Long id; @JoinColumn(name = "parent_id") @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) private Parent parent; } @Entity public class Parent { @Id private Long id; } 

在我们的示例中,子实体是懒惰的:我们不想在接收Child时加载不必要的数据(并在SQL查询中联接另一个表)。 但是在某些情况下,在我们的应用程序中我们肯定知道我们将同时需要孩子和他的父母。 如果您让实体懒惰,我们将收到2个单独的请求。 如果您通过删除FetchType.LAZY应用快速加载,则两个实体将始终在第一个请求时加载(我们不希望这样做)。


JPQL提供了一个很好的解决方案-这是fetch关键字:


 public interface ChildRepository extends JpaRepository<Child, Long> { @Query("select c from Child c join fetch c.parent where c.id = :id") Child findByIdFetchParent(@Param("id") Long id); } 

该请求简单明了,但有缺点:


  • JpaRepository::findById添加显式加载,我们实际上复制了JpaRepository::findById的逻辑
  • 在应用程序启动时@Query检查使用@Query描述的每个查询,这需要解析查询,检查参数等。(请参阅org.springframework.data.jpa.repository.query.SimpleJpaQuery :: validateQuery )。 所有这些都是需要时间和记忆的工作。
  • 在具有数十个存储库和交织的实体(有时有十二个“女儿”)的大型项目中使用这种方法将导致组合爆炸。

关键在于我们的援助:


 @Entity @NamedEntityGraphs(value = { @NamedEntityGraph( name = Child.PARENT, attributeNodes = @NamedAttributeNode("parent") ) }) public class Child { public static final String PARENT = "Child[parent]"; @Id private Long id; @JoinColumn(name = "parent_id") @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) private Parent parent; } 

该图本身很容易描述;使用时会遇到困难。 Spring Data JPA在其页面上建议这样做(适用于我们的案例):


 public interface GroupRepository extends JpaRepository<GroupInfo, String> { @EntityGraph(value = Child.PARENT) @Query("select c from Child c where c.id = :id") Child findByIdFetchParent(@Param("id") Long id); } 

在这里,我们看到了所有相同的问题(除了书面请求变得更加简单)。 您可以在微调的帮助下一口气结束它们。 创建您自己的接口,我们将使用它来构建存储库,而不是使用盒装的JpaRepository


 @NoRepositoryBean public interface BaseJpaRepository<T, ID extends Serializable> extends JpaRepository<T, ID> { T findById(ID id, String graphName); } 

现在执行:


 public class BaseJpaRepositoryImpl<T, ID extends Serializable> extends SimpleJpaRepository<T, ID> implements BaseJpaRepository<T, ID> { private final JpaEntityInformation<T, ?> entityInfo; private final EntityManager entityManager; public BaseJpaRepositoryImpl(JpaEntityInformation<T, ?> ei, EntityManager em) { super(ei, em); this.entityInfo = ei; this.entityManager = em; } @Override public T findById(ID id, String graphName) { Assert.notNull(id, "The given id must not be null!"); //  EntityGraph<?> graph = entityManager.getEntityGraph(graphName); Map<String, Object> hints = singletonMap(QueryHints.HINT_LOADGRAPH, graph); return entityManager.find(getDomainClass(), id, hints); } 

现在,Spring不得不使用BaseJpaRepositoryImpl作为应用程序所有存储库的基础:


 @EnableJpaRepositories(repositoryBaseClass = BaseJpaRepositoryImpl.class) public class AppConfig { } 

现在,从BaseJpaRepository继承的所有存储库中都可以使用我们的方法。


这种方法有一个缺点,那就是可以放一个非常肥的猪。


试着为自己思考

问题在于,Hibernate(至少在编写本文时)与图的名称和图本身不匹配。 因此,当我们执行类似的操作时,可能会出现运行时错误


 Optional<MyEntity> entity = repository.findById(id, NON_EXISTING_GRAPH); 

您可以使用测试来检查解决方案的运行状况:


 @Sql("/ChildRepositoryGraphTest.sql") public class ChildRepositoryGraphTest extends TestBase { private final Long childId = 1L; @Test public void testGraph_expectFieldInitialized() { Child child1 = childRepository.findOne(childId, Child.PARENT); boolean initialized = Hibernate.isInitialized(child1.getParent()); assertTrue(initialized); } @Test public void testGraph_expectFieldNotInitialized() { Child child1 = childRepository .findById(childId) .orElseThrow(NullPointerException::new); boolean initialized = Hibernate.isInitialized(child1.getParent()); assertFalse(initialized); } } 

树木大的时候


而且我们很小,没有经验,我们经常不得不看下面的代码:


 public List<DailyRecord> findBetweenDates(Date from, Date to) { StringBuilder query = new StringBuilder("from Record "); if (from != null) { query.append(" where date >=").append(format(from)).append(" "); } if (to != null) { if (from == null) { query.append(" where date <= " + format(to) + " "); } else { query.append(" and date <= " + format(to) + " "); } } return em.createQuery(query.toString(), DailyRecord.class).getResultList(); } 

此代码逐段收集请求。 这种方法的缺点很明显:


  • 与你的手有很大关系
  • 有手工工作的地方-有错误
  • 不突出显示语法(运行时会弹出打字错误)
  • 扩展和维护代码非常困难

不久之后,出现了Criteria API,这使我们可以将上面的代码压缩一点:


 public List<DailyRecord> findBetweenDates(Date from, Date to) { Criteria criteria = em .unwrap(Session.class) .createCriteria(DailyRecord.class); if (from != null) { criteria.add(Expression.ge("date", from)); } if (to != null) { criteria.add(Expression.le("date", to)); } return criteria.list(); } 

使用条件有几个优点:


  • 使用元模型代替“日期”等“有线”值的能力
  • 构造请求时出现的一些错误是编译错误,即在编写时已经检测到它们
  • 与愚蠢的粘贴字符串相比,该代码更短,更易于理解

也有缺点:


  • 代码很复杂,足以理解
  • 要学习如何编写此类查询,您需要付出很多努力(我记得我第一次不得不在此类查询中进行纠错时遇到的最大痛苦,有时包括100-150行,带有分支等)
  • 复杂的查询相当麻烦(距离限制不超过50行)

我想轻松愉快地开发它,所以我不喜欢这两种方法。


让我们转到我们已经检查过的实体:


 @Entity public class Child { @Id private Long id; @JoinColumn(name = "parent_id") @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) private Parent parent; //... @OneToMany(mappedBy = "owner", cascade = CascadeType.ALL) @LazyCollection(value = LazyCollectionOption.EXTRA) private List<Toy> toys = new ArrayList<>(); } 

我希望能够以不同的模式(及其组合)加载实体:


  • 加载(或不加载)父级
  • 加载(或不加载)玩具
  • 按年龄分类儿童

如果您直接解决此问题,即通过编写许多与所选加载模式相对应的查询,那么很快就会导致组合爆炸:


  @Query("select c from Child c join fetch c.parent order by c.age") List<Child> findWithParentOrderByAge(); @Query("select c from Child c join fetch c.toys order by c.age") List<Child> findWithToysOrderByAge(); @Query("select c from Child c join fetch c.parent join fetch c.toys") List<Child> findWithParentAndToys(); //... 

有一个简单而优雅的方法可以解决此问题:SQL / HQL和模板引擎的组合。 我的项目中使用了“ Freemarker”,尽管可以使用其他解决方案(“ Timlif”,“ Mustash”等)。


让我们开始创建。 首先,我们需要在一个接收扩展名*.hql.ftl*.sql.ftl (如果使用“纯” SQL)的文件中描述查询:


 #* @vtlvariable name="fetchParent" type="java.lang.Boolean" *# #* @vtlvariable name="fetchToys" type="java.lang.Boolean" *# #* @vtlvariable name="orderByAge" type="java.lang.Boolean" *# select child from Child child #if($fetchParent) left join fetch child.parent #end #if($fetchToys) left join fetch child.toys #end #if($orderByAge) order by child.age #end 

现在您需要一个处理程序:


 @Component @RequiredArgsConstructor public class TemplateParser { private final Configuration configuration; @SneakyThrows public String prepareQuery(String templateName, Map<String, Object> params){ Template template = configuration.getTemplate(templateName); return FreeMarkerTemplateUtils.processTemplateIntoString(template, params); } } 

没什么复杂的。 进入存储库。 显然,继承JpaRepository的接口不适合我们。 而是利用这个机会来创建自己的存储库:


 public interface ChildRepositoryCustom { List<Child> findAll(boolean fetchParent, boolean fetchToys, boolean order); } @RequiredArgsConstructor public class ChildRepositoryImpl extends BaseDao implements ChildRepositoryCustom { private final TemplateParser templateParser; @Override public List<Child> findAll(boolean fetchParent, boolean fetchToys, boolean order) { Map<String, Object> params = new HashMap<>(); params.put("fetchParent", fetchParent); params.put("fetchToys", fetchToys); params.put("orderByAge", orderByAge); String query = templateParser.prepareQuery(BASE_CHILD_TEMPLATE.name, params); return em.createQuery(query, Child.class).getResultList(); } @RequiredArgsConstructor enum RepositoryTemplates { BASE_CHILD_TEMPLATE("BaseChildTemplate.hql.ftl"); public final String name; } } 

为了使findUsingTemplate方法可从ChildRepository访问ChildRepository您需要执行以下操作:


 public interface ChildRepository extends BaseJpaRepository<Child, Long>, ChildRepositoryCustom { //... } 

与名称相关的重要功能

Spring将仅使用正确的名称将我们的类和接口绑定在一起:


  • 子存储库
  • ChildRepository 自定义
  • ChildRepository Impl

请记住这一点,因为如果名称中有错误,将引发难以理解的异常,无法从中理解错误原因。


现在,使用这种方法可以解决更复杂的问题。 假设我们需要根据用户选择的特征进行选择。 换句话说,如果用户未指定日期“从”和“到”,那么将没有时间过滤。 如果仅指定日期“ from”或仅日期“ to”,则过滤将是单向的。 如果同时指定了两个日期,则只有指定日期之间的记录才属于选择范围:


 @Getter @RequiredArgsConstructor public class RequestDto { private final LocalDate from; private final LocalDate to; public boolean hasDateFrom() { return from != null; } public boolean hasDateTo() { return to != null; } } @Override public List<Child> findAll(ChildRequest request) { Map<String, Object> params = singletonMap("request", request); String query = templateParser.prepareQuery(TEMPLATE.name, params); return em.createQuery(query, Child.class).getResultList(); } 

现在是模板:


 <#-- @ftlvariable name="request" type="...RequestDto" --> select child from Child child <#if request.hasDateFrom() && request.hasDateTo()> where child.birthDate >= :dateFrom and child.birthDate <= :dateTo <#elseif request.hasDateFrom()> where child.birthDate >= :dateFrom <#elseif request.hasDateTo()> where child.birthDate <= :dateTo </#if> 

Oracle和NVL


考虑本质:


 @Entity public class DailyRecord { @Id private Long id; @Column private String currency; @Column(name = "record_rate") private BigDecimal rate; @Column(name = "fixed_rate") private BigDecimal fxRate; @Setter(value = AccessLevel.PRIVATE) @Formula("select avg(r.record_rate) from daily_record r where r.currency = currency") private BigDecimal avgRate; } 

该实体用于查询(DBMS,我们记得有Oracle):


 @Query("select nvl(record.fxRate, record.avgRate) " + " from DailyRecord record " + "where record.currency = :currency") BigDecimal findRateByCurrency(@Param("currency") String currency); 

这是一个有效的请求。 但是它有一个小问题,SQL专家可能会指出。 事实是Oracle中的nvl并不懒惰。 换句话说,当我们调用findRateByCurrency方法时, findRateByCurrency日志将findRateByCurrency


 select nvl( dr.fixed_rate, select avg(r.record_rate) from daily_record r where r.currency = dr.currency ) from daily_record dr where dr.currency = ? 

即使存在dr.fixed_rate值,DBMS仍会计算nvl第二个表达式返回的值。


 select avg(r.record_rate) from daily_record r where r.currency = dr.currency) 

读者可能已经知道如何避免不必要的请求权重:当然,这是关键字coalesce ,它与nvl懒惰性,并且可以接受两个以上的表达式,因此具有优势。 让我们更正我们的请求:


 @Query("select coalesce(record.fxRate, record.avgRate) " + " from DailyRecord record " + "where record.currency = :currency") BigDecimal findRateByCurrency(@Param("currency") String currency); 

然后,正如他们所说,突然之间:


 select nvl(dr.fixed_rate, select avg(r.record_rate) from daily_record r where r.currency = dr.currency) from daily_record dr where dr.currency = ? 

要求保持不变。 那是因为盒子里的甲骨文方言变成了nvl链。


备注

如果要重现此行为,请删除CustomOracleDialect类的构造函数中的第二行,然后运行DailyRecordRepositoryTest::findRateByCurrency


要避免这种情况,您需要创建自己的方言并在应用程序中使用它:


 public class CustomOracleDialect extends Oracle12cDialect { public CustomOracleDialect() { super(); registerFunction("coalesce", new StandardSQLFunction("coalesce")); } } 

是的,就是这么简单。 现在,将创建的方言绑定到应用程序:


 spring: jpa: database-platform: com.luxoft.logeek.config.CustomOracleDialect 

另一种(不建议使用的)方式:
 spring: jpa: properties: hibernate.dialect: com.luxoft.logeek.config.CustomOracleDialect 

重复执行该请求将使梦ko以求的koalesk:


 select coalesce(dr.fixed_rate, select avg(r.record_rate) from daily_record r where r.currency = dr.currency) from daily_record dr where dr.currency = ? 

Oracle和页面请求


通常,方言的完成为查询操作提供了丰富的机会。 通常,在开发应用程序和网络界面时,会遇到分页上传数据的任务。 换句话说,我们在数据库中有数十万条记录,但是它们以每页10/50/100条记录的包的形式显示。 开箱即用的Spring Date为开发人员提供了类似的功能:


 @Query("select new com.luxoft.logeek.data.BriefChildData(" + "c.id, " + "c.age " + ") from Child c " + " join c.parent p " + "where p.name = ''") Page<BriefChildData> browse(Pageable pageable); 

这种方法有一个很大的缺点,即执行两个查询,第一个查询获取数据,第二个查询确定数据库中它们的总数(这对于显示Page对象中的数据总量是必需的)。 在我们的例子中,对此方法的调用给出了以下请求(使用p6spy进行记录):


 select * from ( select c.id, c.age from child c inner join parent p on c.parent_id = p.id where p.name = '' ) where rownum <= 3; select count(c.id) from child c inner join parent p on c.parent_id = p.id where p.name = '' 

如果查询很繁琐(许多表由不可索引的列连接,只有很多连接,困难的选择条件等),那么这可能会成为问题。 但是由于我们拥有Oracle,因此使用rownum伪列可以通过一个请求获得请求。


为此,我们需要完成方言,并描述用于计数所有记录的函数:


 public class CustomOracleDialect extends Oracle12cDialect { public CustomOracleDialect() { super(); registerFunction("coalesce", new StandardSQLFunction("coalesce")); registerFunction("total_count", new TotalCountFunc()); } } public class TotalCountFunc implements SQLFunction { @Override public boolean hasArguments() { return true; } @Override public boolean hasParenthesesIfNoArguments() { return true; } @Override public Type getReturnType(Type type, Mapping mapping) { return StandardBasicTypes.LONG; } @Override public String render(Type type, List arguments, SessionFactoryImplementor factory) { if (arguments.size() != 1) { throw new IllegalArgumentException("Only 1 argument acceptable"); } return " count(" + arguments.get(0) + ") over () "; } } 

现在编写一个新查询(在ChildRepositoryImpl类中):


 @Override public Page<BriefChildData> browseWithTotalCount(Pageable pageable) { String query = "select " + " c.id as id," + " c.age as age, " + " total_count(c.id) as totalCount" + " from Child c " + "join c.parent p " + "where p.name = ''"; List<BriefChildData> list = em.unwrap(Session.class) .createQuery(query) .setFirstResult((int) pageable.getOffset()) .setMaxResults(pageable.getPageSize()) .setResultTransformer(Transformers.aliasToBean(BriefChildData.class)) .getResultList(); if (list.isEmpty()) { return new PageImpl(Collections.emptyList()); } long totalCount = list.get(0).getTotalCount(); return new PageImpl<>(list, pageable, totalCount); } 

调用此代码时,将执行一个请求


 select * from (select c.id, c.age, count(c.id) over () -- <----- from child c inner join parent p on c.parent_id = p.id where p.name = '') where rownum <= 3 

count(c.id) over ()使用表达式count(c.id) over ()您可以获取数据总量,并从数据类中获取数据,以传递给PageImpl构造函数。 有一种方法可以更优雅地完成它,而无需在数据类中添加另一个字段,而要考虑它是一项功课:)您可以使用ProjectionVsDataTest测试来测试解决方案。


定制的陷阱


我们在Oracle和Spring Date上有一个很棒的项目。 我们的任务是提高此类代码的性能:


 List<Long> ids = getIds(); ids.stream() .map(repository::findById) .filter(Optional::isPresent) .map(Optional::get) .forEach(this::sendToSchool); 

缺点在于表面上:数据库查询的数量等于唯一键的数量。 有一种克服此困难的已知方法:


 List<Long> ids = getIds(); repository .findAllById(ids) .forEach(this::sendToSchool); 

多次采样的优势是显而易见的:如果早些时候,我们有许多类似形式的查询


 select p.* from Pupil p where p.id = 1 select p.* from Pupil p where p.id = 2 select p.* from Pupil p where p.id = 3 

那么现在他们已经崩溃了


 select p.* from Pupil p where p.id in (1, 2, 3, ... ) 

它似乎更容易变得更好。 该项目不断发展,发展,数据成倍增长,一旦不可避免,该项目就将出现:


晴间的Aki雷声
 ERROR - ORA-01795: maximum number of expressions in a list is 1000 

我们需要再次寻找出路(不要返回到旧版本)。 由于Oracle不允许他提供超过1000个键,因此您可以将整个数据集划分为不超过1000个相等的份额,并执行多个查询:


 List<List<Long>> idChunks = cgccLists.partition(ids, 1000); //* idChunks.forEach(idChunk -> repository.findAllById(idChunk).forEach(this::sendToSchool) ); //* cgccLists = com.google.common.collect.Lists 

此方法有效,但闻起来有点(是吗?):如果您在其他地方遇到此类困难,则必须围护同一花园。 让我们尝试更优雅地解决问题,即BaseJpaRepositoryImpl 。 最简单的方法是将上述逻辑向内传递,对用户隐藏:


 @Override public List<T> findAllById(Iterable<ID> ids) { Assert.notNull(ids, "The given Iterable of Id's must not be null!"); Set<ID> idsCopy = Sets.newHashSet(ids); if (idsCopy.size() <= OracleConstants.MAX_IN_COUNT) { return super.findAllById(ids); } return findAll(idsCopy); } private List<T> findAll(Collection<ID> ids) { List<List<ID>> idChunks = Lists.partition(new ArrayList<>(ids), 1000); return idChunks .stream() .map(this::findAllById) .flatMap(List::stream) .collect(Collectors.toList()); } 

情况变得越来越好:首先,我们从基础结构层清除了工作代码,其次,我们将解决方案的范围扩展到了所有扩展BaseJpaRepository项目存储库。 也有缺点。 主要的是几个请求而不是一个请求,还有(来自主要请求的词干)-需要过滤键,因为如果不这样做,那么相同的键可能会出现在不同的idChunks 。 反过来,这意味着同一实体将被两次包含在列表中,因此将被处理两次。 我们不需要这个,因此这是另一个更复杂的解决方案:


 @Override public List<T> findAllById(Iterable<ID> ids) { Assert.notNull(ids, "The given Iterable of Id's must not be null!"); ArrayList<ID> idsCopy = Lists.newArrayList(ids); if (idsCopy.size() <= OracleConstants.MAX_IN_COUNT) { return super.findAllById(ids); } return findAll(idsCopy); } private List<T> findAll(ArrayList<ID> ids) { CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<T> query = cb.createQuery(getDomainClass()); Root<T> from = query.from(getDomainClass()); Predicate predicate = toPredicate(cb, ids, from); query = query.select(from).where(predicate); return entityManager.createQuery(query).getResultList(); } private Predicate toPredicate(CriteriaBuilder cb, ArrayList<ID> ids, Root<T> root) { List<List<ID>> chunks = Lists.partition(ids, OracleConstants.MAX_IN_COUNT); SingularAttribute<? super T, ?> id = entityInfo.getIdAttribute(); Predicate[] predicates = chunks.stream() .map(chunk -> root.get(id).in(chunk)) .toArray(Predicate[]::new); return cb.or(predicates); } 

它使用Criteria API,这使得构建一个最终查询形式成为可能。


 select p.* from Pupil p where p.id in (1, 2, ... , 1000) or p.id in (1001, ... , 2000) or p.id in (2001, ... , 3000) 

有一个微妙之处:由于条件繁琐,类似的请求可以比平常执行更长的时间,因此第一种方法可能(有时)是更可取的。


就这样,我希望这些示例对您有用,并且对日常开发有用。 欢迎评论和添加。

Source: https://habr.com/ru/post/zh-CN444240/


All Articles