Salam, ini adalah posting kedua tentang Spring Data JPA. Bagian pertama sepenuhnya dikhususkan untuk garu bawah air, serta tips dari yang berpengalaman. Pada bagian ini kita akan berbicara tentang bagaimana mempertajam kerangka kerja dengan kebutuhan Anda. Semua contoh yang dijelaskan tersedia di sini .
Hitungan
Mari kita mulai, mungkin, dengan tugas umum yang sederhana dan sekaligus: ketika memuat suatu entitas, perlu untuk mengunduh "putrinya" secara selektif. Pertimbangkan contoh sederhana:
@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; }
Entitas anak dalam contoh kami malas: kami tidak ingin memuat data yang tidak perlu (dan bergabung dengan tabel lain dalam kueri SQL) saat menerima Child
. Tetapi dalam beberapa kasus dalam aplikasi kita, kita tahu pasti bahwa kita akan membutuhkan anak dan orang tuanya. Jika Anda membiarkan entitas malas, kami mendapat 2 permintaan terpisah. Jika Anda menerapkan pemuatan cepat dengan menghapus FetchType.LAZY
, maka kedua entitas akan selalu dimuat pada permintaan pertama (dan kami tidak menginginkan ini).
JPQL memberikan solusi yang baik di luar kotak - ini adalah kata kunci 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); }
Permintaan ini sederhana dan jelas, tetapi memiliki kekurangan:
- kami benar-benar menggandakan logika
JpaRepository::findById
menambahkan pemuatan eksplisit - setiap kueri yang dijelaskan menggunakan
@Query
diperiksa pada saat startup aplikasi, yang membutuhkan penguraian kueri, memeriksa argumen, dll. (lihat org.springframework.data.jpa.repository.query.SimpleJpaQuery :: validateQuery ). Semua ini adalah pekerjaan yang membutuhkan waktu dan memori. - penggunaan pendekatan semacam itu dalam proyek besar dengan lusinan repositori dan entitas terjalin (kadang-kadang dengan selusin "anak perempuan") akan menyebabkan ledakan kombinatorial.
Hitungan datang ke bantuan kami:
@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; }
Grafik itu sendiri mudah dijelaskan, kesulitan mulai saat menggunakannya. Spring Data JPA di halamannya menyarankan melakukannya dengan cara ini (sebagaimana berlaku untuk kasus kami):
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); }
Di sini kita melihat semua masalah yang sama (kecuali bahwa permintaan tertulis menjadi sedikit lebih mudah). Anda dapat mengakhirinya dalam satu gerakan dengan bantuan fine-tuning. Buat antarmuka Anda sendiri, yang akan kami gunakan untuk membangun repositori alih-alih JpaRepository
kotak:
@NoRepositoryBean public interface BaseJpaRepository<T, ID extends Serializable> extends JpaRepository<T, ID> { T findById(ID id, String graphName); }
Sekarang implementasi:
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!");
Sekarang mewajibkan Spring untuk menggunakan BaseJpaRepositoryImpl
sebagai dasar untuk semua repositori aplikasi kita:
@EnableJpaRepositories(repositoryBaseClass = BaseJpaRepositoryImpl.class) public class AppConfig { }
Sekarang metode kami akan tersedia dari semua repositori yang diwarisi dari BaseJpaRepository
kami.
Pendekatan ini memiliki satu kelemahan yang bisa menempatkan babi sangat gemuk.
Cobalah untuk berpikir sendiriMasalahnya adalah Hibernate (setidaknya pada saat penulisan) tidak cocok dengan nama grafik dan grafik itu sendiri. Karena itu, mungkin ada kesalahan runtime ketika kita menjalankan sesuatu seperti
Optional<MyEntity> entity = repository.findById(id, NON_EXISTING_GRAPH);
Anda dapat memeriksa kesehatan solusi menggunakan tes:
@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); } }
Saat itu pohon-pohon besar
Dan kami kecil dan tidak berpengalaman, kami sering harus melihat kode ini:
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(); }
Kode ini mengumpulkan permintaan sepotong demi sepotong. Kerugian dari pendekatan ini jelas:
- Anda harus melakukan banyak hal dengan tangan Anda
- di mana ada pekerjaan manual - ada kesalahan
- tidak ada penyorotan sintaks (kesalahan ketik muncul saat runtime)
- sangat sulit untuk memperluas dan memelihara kode
Beberapa saat kemudian, API Kriteria muncul, yang memungkinkan kami memeras kode di atas sedikit:
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(); }
Menggunakan kriteria memiliki beberapa keunggulan:
- kemampuan untuk menggunakan metamodel alih-alih nilai "kabel" seperti "tanggal"
- beberapa kesalahan dalam membangun permintaan ternyata merupakan kesalahan kompilasi, mis., mereka sudah terdeteksi saat menulis
- kode lebih pendek dan lebih dapat dipahami daripada dengan string paste bodoh
Ada juga kelemahannya:
- kode ini cukup rumit untuk dimengerti
- untuk mempelajari cara menulis pertanyaan seperti itu, Anda perlu mengisi tangan Anda (saya ingat rasa sakit terliar ketika saya pertama kali harus berurusan dengan koreksi kesalahan dalam pertanyaan seperti itu, kadang-kadang terdiri dari 100-150 baris dengan percabangan, dll.)
- kueri yang rumit agak rumit (50 baris jauh dari batas)
Saya ingin mengembangkannya dengan mudah dan dengan senang hati, jadi saya tidak suka kedua metode ini.
Mari kita beralih ke entitas yang sudah kita periksa:
@Entity public class Child { @Id private Long id; @JoinColumn(name = "parent_id") @ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL) private Parent parent;
Saya ingin dapat memuat entitas dalam mode yang berbeda (dan kombinasinya):
- memuat (atau tidak) induk
- memuat (atau tidak) mainan
- urutkan anak berdasarkan usia
Jika Anda menyelesaikan masalah ini secara langsung, yaitu, dengan menulis banyak kueri yang terkait dengan mode pemuatan yang dipilih, maka dengan sangat cepat ini akan menyebabkan ledakan kombinatorial:
@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();
Ada cara sederhana dan elegan untuk menyelesaikan masalah ini: kombinasi dari SQL / HQL dan mesin template. "Freemarker" digunakan pada proyek saya, meskipun solusi lain dapat digunakan ("Timlif", "Mustash", dll.).
Mari mulai membuat. Pertama-tama, kita perlu menjelaskan kueri dalam file yang menerima ekstensi *.hql.ftl
atau *.sql.ftl
(jika menggunakan SQL "murni"):
Sekarang Anda membutuhkan penangan:
@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); } }
Tidak ada yang rumit. Pergi ke repositori. Jelas, antarmuka yang mewarisi JpaRepository
tidak cocok untuk kita. Sebaliknya, kami akan menggunakan kesempatan untuk membuat repositori kami sendiri:
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; } }
Untuk membuat metode findUsingTemplate
dapat diakses dari ChildRepository
Anda perlu melakukan ini:
public interface ChildRepository extends BaseJpaRepository<Child, Long>, ChildRepositoryCustom {
Fitur penting yang terkait dengan namaSpring akan mengikat kelas dan antarmuka kita hanya dengan nama yang benar:
- Gudang anak
- ChildRepository Custom
- ChildRepository Impl
Ingat ini, karena dalam kasus kesalahan dalam nama pengecualian yang tidak dapat dipahami akan dilempar, dari mana tidak mungkin untuk memahami penyebab kesalahan.
Sekarang menggunakan pendekatan ini Anda dapat memecahkan masalah yang lebih kompleks. Misalkan kita perlu membuat seleksi berdasarkan karakteristik yang dipilih pengguna. Dengan kata lain, jika pengguna tidak menentukan tanggal "dari" dan "ke", maka tidak akan ada penyaringan waktu. Jika hanya tanggal "dari" atau hanya tanggal "ke" yang ditentukan, pemfilteran akan satu arah. Jika kedua tanggal ditentukan, maka hanya catatan antara tanggal yang ditentukan akan jatuh ke dalam pilihan:
@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(); }
Sekarang templat:
<
Oracle dan nvl
Pertimbangkan esensinya:
@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; }
Entitas ini digunakan dalam kueri (DBMS, seperti yang kita ingat, kita memiliki Oracle):
@Query("select nvl(record.fxRate, record.avgRate) " + " from DailyRecord record " + "where record.currency = :currency") BigDecimal findRateByCurrency(@Param("currency") String currency);
Ini adalah permintaan yang berfungsi dan valid. Tapi ada satu masalah kecil dengan itu, yang ahli SQL mungkin akan tunjukkan. Faktanya adalah bahwa nvl
di Oracle tidak malas. Dengan kata lain, ketika kita memanggil metode findRateByCurrency
, log findRateByCurrency
akan 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 = ?
Bahkan jika nilai dr.fixed_rate
hadir, DBMS masih menghitung nilai yang dikembalikan oleh ekspresi kedua di nvl
, yang dalam kasus kami
select avg(r.record_rate) from daily_record r where r.currency = dr.currency)
Pembaca mungkin sudah tahu bagaimana menghindari bobot permintaan yang tidak perlu: tentu saja, ini adalah kata kunci yang coalesce
, yang lebih baik dibandingkan dengan nvl
kemalasannya, serta kemampuan untuk menerima lebih dari 2 ekspresi. Mari kita perbaiki permintaan kami:
@Query("select coalesce(record.fxRate, record.avgRate) " + " from DailyRecord record " + "where record.currency = :currency") BigDecimal findRateByCurrency(@Param("currency") String currency);
Dan kemudian, seperti yang mereka katakan, tiba-tiba:
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 = ?
Permintaannya tetap sama. Itu karena dialek oracle dari kotak mengubah coalesce
menjadi rantai nvl
.
KomentarJika Anda ingin mereproduksi perilaku ini, hapus baris kedua dalam konstruktor kelas CustomOracleDialect dan jalankan DailyRecordRepositoryTest::findRateByCurrency
Untuk menghindarinya, Anda perlu membuat dialek Anda sendiri dan menggunakannya dalam aplikasi:
public class CustomOracleDialect extends Oracle12cDialect { public CustomOracleDialect() { super(); registerFunction("coalesce", new StandardSQLFunction("coalesce")); } }
Ya, itu sangat sederhana. Sekarang kita ikat dialek yang dibuat ke aplikasi:
spring: jpa: database-platform: com.luxoft.logeek.config.CustomOracleDialect
Cara lain (usang): spring: jpa: properties: hibernate.dialect: com.luxoft.logeek.config.CustomOracleDialect
Eksekusi permintaan yang berulang kali memberi koalesk yang didambakan:
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 dan permintaan halaman
Secara umum, penyelesaian dialek memberikan banyak peluang untuk manipulasi kueri. Seringkali saat mengembangkan aplikasi dan wajah web, tugas mengunggah data secara bijaksana terjadi. Dengan kata lain, kami memiliki beberapa ratus ribu catatan dalam database, tetapi mereka ditampilkan dalam paket 10/50/100 catatan per halaman. Tanggal Musim Semi di luar kotak memberikan fungsi yang sama kepada pengembang:
@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);
Pendekatan ini memiliki kelemahan yang signifikan, yaitu, pelaksanaan dua pertanyaan, yang pertama mendapatkan data, dan yang kedua - menentukan jumlah totalnya dalam database (ini diperlukan untuk menampilkan jumlah total data dalam objek Page
). Dalam kasus kami, panggilan ke metode ini memberikan permintaan berikut (masuk menggunakan 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 = ''
Jika kueri itu berat (banyak tabel digabung dengan kolom yang tidak dapat diindeks, hanya banyak yang bergabung, kondisi pemilihan yang sulit, dll.), Maka ini bisa menjadi masalah. Tetapi karena kita memiliki Oracle, maka menggunakan rownum pseudo-kolom Anda dapat bertahan dengan satu permintaan.
Untuk melakukan ini, kita perlu menyelesaikan dialek kita, dan menjelaskan fungsi yang digunakan untuk menghitung semua catatan:
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 () "; } }
Sekarang tulis permintaan baru (di kelas 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); }
Saat memanggil kode ini, satu permintaan akan dieksekusi
select * from (select c.id, c.age, count(c.id) over ()
Dengan menggunakan jumlah ekspresi count(c.id) over ()
Anda bisa mendapatkan jumlah total data dan mendapatkannya dari kelas data untuk diteruskan ke konstruktor PageImpl
. Ada cara untuk melakukannya dengan lebih elegan, tanpa menambahkan bidang lain ke kelas data, anggap itu pekerjaan rumah :) Anda dapat menguji solusinya menggunakan tes ProjectionVsDataTest .
Perangkap kustomisasi
Kami memiliki proyek keren dengan Oracle dan Spring Date. Tugas kami adalah meningkatkan kinerja kode tersebut:
List<Long> ids = getIds(); ids.stream() .map(repository::findById) .filter(Optional::isPresent) .map(Optional::get) .forEach(this::sendToSchool);
Kerugiannya terletak di permukaan: jumlah permintaan basis data sama dengan jumlah kunci unik. Ada metode yang diketahui untuk mengatasi kesulitan ini:
List<Long> ids = getIds(); repository .findAllById(ids) .forEach(this::sendToSchool);
Keuntungan dari multiple sampling jelas: jika sebelumnya kami memiliki banyak pertanyaan serupa
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
maka sekarang mereka telah runtuh menjadi satu
select p.* from Pupil p where p.id in (1, 2, 3, ... )
Tampaknya menjadi lebih mudah dan menjadi baik. Proyek ini tumbuh, berkembang, data berlipat ganda dan begitu hal yang tak terhindarkan datang:
Aki mengguntur di langit yang cerah ERROR - ORA-01795: maximum number of expressions in a list is 1000
Kita perlu mencari jalan keluar lagi (tidak kembali ke versi lama). Karena Oracle tidak memperbolehkannya untuk memberi makan lebih dari 1000 kunci, maka Anda dapat membagi seluruh set data menjadi bagian yang sama dengan tidak lebih dari 1000 dan menjalankan beberapa pertanyaan:
List<List<Long>> idChunks = cgccLists.partition(ids, 1000);
Metode ini berhasil, tetapi baunya sedikit (kan?): Jika Anda menemui kesulitan seperti itu di tempat lain, Anda harus memagari taman yang sama. Mari kita coba untuk menyelesaikan masalah dengan lebih elegan, yaitu dengan BaseJpaRepositoryImpl
. Cara termudah untuk melakukan ini adalah mentransfer logika di atas ke dalam, menyembunyikannya dari pengguna:
@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()); }
Itu menjadi lebih baik: pertama, kami membersihkan kode kerja dari lapisan infrastruktur, dan kedua, kami memperluas cakupan solusi kami ke semua repositori proyek yang memperluas BaseJpaRepository
. Ada juga kekurangannya. Yang utama adalah beberapa permintaan, bukan satu, dan juga (berasal dari yang utama) - kebutuhan untuk memfilter kunci, karena jika Anda tidak melakukan ini, maka kunci yang sama dapat muncul di idChunks
berbeda. Ini, pada gilirannya, berarti bahwa entitas yang sama akan dimasukkan dalam daftar dua kali, dan, karenanya, akan diproses dua kali. Kami tidak membutuhkan ini, jadi inilah solusi lain yang lebih canggih:
@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); }
Itu menggunakan Kriteria API, yang memungkinkan untuk membangun satu permintaan akhir formulir
select p.* from Pupil p where p.id in (1, 2, ... , 1000) or p.id in (1001, ... , 2000) or p.id in (2001, ... , 3000)
Ada kehalusan: permintaan serupa dapat dieksekusi lebih lama dari biasanya karena kondisi rumit, sehingga metode pertama mungkin (kadang-kadang) lebih disukai.
Itu saja, saya berharap contoh-contohnya bermanfaat bagi Anda dan berguna dalam pengembangan sehari-hari. Komentar dan tambahan dipersilahkan.