Spring Data JPA: apa yang baik dan apa yang buruk

Anak bayi datang ke ayahnya
Dan bertanya pada bayi itu
- Apa yang baik?
dan apa yang buruk

Vladimir Mayakovsky


Artikel ini adalah tentang Spring Data JPA, yaitu di rake bawah air yang saya temui dalam perjalanan, dan tentu saja sedikit tentang kinerja.


Contoh yang dijelaskan dalam artikel dapat dijalankan di lingkungan pengujian, dapat diakses dengan referensi .


Catatan untuk mereka yang belum pindah ke Spring Boot 2

Dalam versi Spring Data JPA 2. * antarmuka utama untuk bekerja dengan repositori, yaitu CrudRepository , dari mana JpaRepository diwarisi, telah JpaRepository . Dalam versi 1. * metode utama tampak seperti ini:


 public interface CrudRepository<T, ID> { T findOne(ID id); List<T> findAll(Iterable<ID> ids); } 

Dalam versi baru:


 public interface CrudRepository<T, ID> { Optional<T> findById(ID id); List<T> findAllById(Iterable<ID> ids); } 

Jadi mari kita mulai.


pilih t. * dari mana t.id di (...)


Salah satu pertanyaan paling umum adalah kueri dari formulir "pilih semua catatan yang kuncinya jatuh ke dalam set yang dikirimkan". Saya yakin hampir semua dari Anda menulis atau melihat sesuatu seperti


 @Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") List<Long> ids); @Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Set<Long> ids); 

Ini berfungsi, permintaan yang sesuai, tidak ada masalah tangkapan atau kinerja, tetapi ada kelemahan kecil, benar-benar tidak mencolok.


Sebelum Anda membuka liner, cobalah untuk berpikir sendiri.

Kerugiannya adalah antarmuka terlalu sempit untuk mengirim kunci. "Jadi apa?" - katamu. "Yah daftarnya, well set, aku tidak melihat masalah di sini." Namun, jika kita melihat metode antarmuka root yang mengambil banyak nilai, maka di mana pun kita melihat Iterable :


"Jadi apa? Dan aku ingin daftar. Kenapa lebih buruk?"
Tidak lebih buruk, bersiaplah untuk penampilan kode serupa di tingkat yang lebih tinggi dalam aplikasi Anda:


 public List<BankAccount> findByUserId(List<Long> userIds) { Set<Long> ids = new HashSet<>(userIds); return repository.findByUserIds(ids); } // public List<BankAccount> findByUserIds(Set<Long> userIds) { List<Long> ids = new ArrayList<>(userIds); return repository.findByUserIds(ids); } 

Kode ini tidak melakukan apa pun kecuali membalikkan koleksi. Mungkin terjadi bahwa argumen ke metode adalah daftar, dan metode repositori menerima set (atau sebaliknya), dan Anda hanya perlu memesan ulang untuk melewati kompilasi. Tentu saja, ini tidak akan menjadi masalah dengan latar belakang biaya overhead untuk permintaan itu sendiri, ini lebih tentang gerakan yang tidak perlu.


Oleh karena itu, praktik yang baik untuk menggunakan Iterable :


 @Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Iterable<Long> ids); 

Z.Y. Jika kita berbicara tentang metode dari *RepositoryCustom , maka masuk akal untuk menggunakan Collection untuk menyederhanakan perhitungan ukuran di dalam implementasi:


 public interface BankAccountRepositoryCustom { boolean anyMoneyAvailable(Collection<Long> accountIds); } public class BankAccountRepositoryImpl { @Override public boolean anyMoneyAvailable(Collection<Long> accountIds) { if (ids.isEmpty()) return false; //... } } 

Kode tambahan: kunci non-duplikat


Sebagai kelanjutan dari bagian terakhir, saya ingin menarik perhatian pada kesalahpahaman umum:


 @Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Set<Long> ids); 

Manifestasi lain dari kesalahan yang sama:


 Set<Long> ids = new HashSet<>(notUniqueIds); List<BankAccount> accounts = repository.findByUserIds(ids); List<Long> ids = ts.stream().map(T::id).distinct().collect(toList()); List<BankAccount> accounts = repository.findByUserIds(ids); Set<Long> ids = ts.stream().map(T::id).collect(toSet()); List<BankAccount> accounts = repository.findByUserIds(ids); 

Sepintas, tidak ada yang aneh, bukan?


Luangkan waktu Anda, pikirkan sendiri;)

HQL / JPQL kueri dari formulir select t from t where t.field in ... akhirnya akan berubah menjadi kueri


 select b.* from BankAccount b where b.user_id in (?, ?, ?, ?, ?, …) 

yang akan selalu mengembalikan hal yang sama terlepas dari adanya pengulangan dalam argumen. Karena itu, untuk memastikan keunikan kunci tidak diperlukan. Ada satu kasus khusus - Oracle, di mana menekan> 1000 kunci menyebabkan kesalahan. Tetapi jika Anda mencoba untuk mengurangi jumlah kunci dengan mengecualikan pengulangan, maka Anda harus lebih memikirkan alasan terjadinya mereka. Kemungkinan besar kesalahan ada di suatu tempat di atas.


Jadi dalam penggunaan kode yang baik Iterable :


 @Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Iterable<Long> ids); 

Samopis


Perhatikan kode ini dari dekat dan temukan di sini tiga kelemahan dan satu kemungkinan kesalahan:


 @Query("from User u where u.id in :ids") List<User> findAll(@Param("ids") Iterable<Long> ids); 

Pikirkan lagi
  • semuanya sudah diimplementasikan di SimpleJpaRepository::findAllById
  • permintaan idle saat melewati daftar kosong (di SimpleJpaRepository::findAllById ada centang yang sesuai)
  • semua pertanyaan yang dijelaskan menggunakan @Query diperiksa pada tahap meningkatkan konteks, yang membutuhkan waktu (tidak seperti SimpleJpaRepository::findAllById )
  • jika Oracle digunakan, ketika kumpulan kunci kosong, kami mendapatkan kesalahan ORA-00936: missing expression (yang tidak akan terjadi ketika menggunakan SimpleJpaRepository::findAllById , lihat poin 2)

Harry potter dan kunci majemuk


Lihatlah dua contoh dan pilih yang Anda sukai:


Metode jumlah kali


 @Embeddable public class CompositeKey implements Serializable { Long key1; Long key2; } @Entity public class CompositeKeyEntity { @EmbeddedId CompositeKey key; } 

Metode nomor dua


 @Embeddable public class CompositeKey implements Serializable { Long key1; Long key2; } @Entity @IdClass(value = CompositeKey.class) public class CompositeKeyEntity { @Id Long key1; @Id Long key2; } 

Sepintas tidak ada perbedaan. Sekarang coba metode pertama dan jalankan tes sederhana:


 //case for @EmbeddedId @Test public void findAll() { int size = entityWithCompositeKeyRepository.findAllById(compositeKeys).size(); assertEquals(size, 5); } 

Di log kueri (Anda menyimpannya, kan?) Kita akan melihat ini:


 select e.key1, e.key2 from CompositeKeyEntity e where e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? 

Sekarang contoh kedua


 //case for @Id @Id @Test public void _findAll() { int size = anotherEntityWithCompositeKeyRepository.findAllById(compositeKeys).size(); assertEquals(size, 5); } 

Log kueri terlihat berbeda:


 select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? 

Itulah perbedaan keseluruhan: dalam kasus pertama kami selalu mendapatkan 1 permintaan, dalam yang kedua - n permintaan.
Alasan untuk perilaku ini terletak pada SimpleJpaRepository::findAllById :


 // ... if (entityInfo.hasCompositeId()) { List<T> results = new ArrayList<>(); for (ID id : ids) { findById(id).ifPresent(results::add); } return results; } // ... 

Metode mana yang terbaik bagi Anda untuk menentukan berdasarkan seberapa penting jumlah permintaan.


Extra CrudRepository :: save


Seringkali dalam kode ada antipattern seperti:


 @Transactional public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return repo.save(account); } 

Pembaca bingung: di mana antipattern? Kode ini hanya terlihat sangat logis: kita mendapatkan entitas - perbarui - simpan. Semuanya seperti di rumah-rumah terbaik di St. Petersburg. Saya berani mengatakan bahwa memanggil CrudRepository::save tidak perlu di sini.


Pertama: metode updateRate transaksional, oleh karena itu, semua perubahan dalam entitas yang dikelola dimonitor oleh Hibernate dan berubah menjadi permintaan ketika Session::flush dieksekusi, yang dalam kode ini terjadi ketika metode berakhir.


Kedua, CrudRepository::save lihat metode CrudRepository::save . Seperti yang Anda ketahui, semua repositori didasarkan pada SimpleJpaRepository . Berikut ini adalah implementasi dari CrudRepository::save :


 @Transactional public <S extends T> S save(S entity) { if (entityInformation.isNew(entity)) { em.persist(entity); return entity; } else { return em.merge(entity); } } 

Ada kehalusan yang tidak semua orang ingat: Hibernate berfungsi melalui peristiwa. Dengan kata lain, setiap tindakan pengguna menghasilkan acara yang antri dan diproses dengan mempertimbangkan peristiwa lainnya dalam antrian yang sama. Dalam kasus ini, panggilan ke EntityManager::merge menghasilkan MergeEvent , yang diproses secara default dalam metode DefaultMergeEventListener::onMerge . Ini berisi logika yang cukup bercabang, tetapi sederhana untuk masing-masing negara dari entitas-argumen. Dalam kasus kami, entitas diperoleh dari repositori di dalam metode transaksional dan berada dalam status PERSISTEN (mis., Pada dasarnya dikendalikan oleh kerangka kerja):


 protected void entityIsPersistent(MergeEvent event, Map copyCache) { LOG.trace("Ignoring persistent instance"); Object entity = event.getEntity(); EventSource source = event.getSession(); EntityPersister persister = source.getEntityPersister(event.getEntityName(), entity); ((MergeContext)copyCache).put(entity, entity, true); this.cascadeOnMerge(source, persister, entity, copyCache); //<---- this.copyValues(persister, entity, entity, source, copyCache); //<---- event.setResult(entity); } 

Iblis ada dalam perinciannya, yaitu dalam metode DefaultMergeEventListener::cascadeOnMerge dan DefaultMergeEventListener::copyValues . Mari kita dengarkan pidato langsung Vlad Mikhalche , salah satu pengembang utama Hibernate:


Dalam panggilan metode copyValues, keadaan terhidrasi disalin lagi, sehingga array baru dibuat secara berlebihan, sehingga membuang siklus CPU. Jika entitas memiliki asosiasi anak dan operasi penggabungan juga mengalir dari entitas induk ke entitas anak, biaya overhead bahkan lebih besar karena setiap entitas anak akan menyebarkan MergeEvent dan siklus berlanjut.

Dengan kata lain, pekerjaan sedang dilakukan yang tidak dapat Anda lakukan. Akibatnya, kode kami dapat disederhanakan sambil meningkatkan kinerjanya:


 @Transactional public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return account; } 

Tentu saja, tidak nyaman untuk mengingat hal ini ketika mengembangkan dan mengoreksi kode orang lain, jadi kami ingin membuat perubahan pada tingkat gambar rangka sehingga metode JpaRepository::save kehilangan properti berbahaya. Apakah ini mungkin?


Ya mungkin
 // @Transactional public <S extends T> S save(S entity) { if (entityInformation.isNew(entity)) { em.persist(entity); return entity; } else { return em.merge(entity); } } // @Transactional public <S extends T> S save(S entity) { if (entityInformation.isNew(entity)) { em.persist(entity); return entity; } else if (!em.contains(entity)) { return em.merge(entity); } return entity; } 

Perubahan ini memang dilakukan pada bulan Desember 2017:
https://jira.spring.io/browse/DATAJPA-931
https://github.com/spring-projects/spring-data-jpa/pull/237


Namun, pembaca yang canggih mungkin sudah merasakan ada sesuatu yang salah. Memang, perubahan ini tidak akan merusak apa pun, tetapi hanya dalam kasus sederhana ketika tidak ada entitas anak:


 @Entity public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO; } 

Sekarang anggap bahwa pemiliknya terikat pada akun:


 @Entity public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO; @ManyToOne @JoinColumn(name = "user_id") User user; } 

Ada metode yang memungkinkan Anda memutuskan koneksi pengguna dari akun dan mentransfer yang terakhir ke pengguna baru:


 @Transactional public BankAccount changeUser(Long id, User newUser) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setUser(newUser); return repo.save(account); } 

Apa yang akan terjadi sekarang? Memeriksa em.contains(entity) akan mengembalikan true, yang berarti em.merge(entity) tidak akan dipanggil. Jika kunci entitas User dibuat berdasarkan urutan (salah satu kasus paling umum), maka itu tidak akan dibuat sampai transaksi selesai (atau Session::flush disebut secara manual), yaitu pengguna akan berada dalam status DETACHED, dan entitas induknya ( akun) - dalam status PERSISTEN. Dalam beberapa kasus, ini dapat mematahkan logika aplikasi, yang terjadi:


02/03/2018 DATAJPA-931 berhenti bergabung dengan RepositoryItemWriter


Dalam hal ini, tugas Kembalikan optimasi yang dibuat untuk entitas yang ada di CrudRepository :: save dibuat dan perubahan dibuat: Kembalikan DATAJPA-931 .


Blind CrudRepository :: findById


Kami terus mempertimbangkan model data yang sama:


 @Entity public class User { @Id Long id; // ... } @Entity public class BankAccount { @Id Long id; @ManyToOne @JoinColumn(name = "user_id") User user; } 

Aplikasi memiliki metode yang membuat akun baru untuk pengguna yang ditentukan:


 @Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); userRepository.findById(userId).ifPresent(account::setUser); //<---- return accountRepository.save(account); } 

Dengan versi 2. * antipattern yang ditunjukkan oleh panah tidak begitu mencolok - lebih jelas terlihat pada versi yang lebih lama:


 @Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.findOne(userId)); //<---- return accountRepository.save(account); } 

Jika Anda tidak melihat cacat \ "dengan mata \", maka lihatlah kueri:
 select u.id, u.name from user u where u.id = ? call next value for hibernate_sequence insert into bank_account (id, /*…*/ user_id) values (/*…*/) 

Permintaan pertama kami dapatkan pengguna dengan kunci. Selanjutnya, kita mendapatkan kunci untuk akun baru lahir dari database dan memasukkannya ke dalam tabel. Dan satu-satunya hal yang kita ambil dari pengguna adalah kuncinya, yang sudah kita miliki sebagai argumen metode. Di sisi lain, BankAccount berisi bidang "pengguna" dan kami tidak dapat membiarkannya kosong (karena orang yang baik kami menetapkan batasan dalam skema). Pengembang berpengalaman mungkin sudah melihat jalannya dan makan ikan, dan naik kuda dapatkan pengguna dan permintaan untuk tidak:


 @Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.getOne(userId)); //<---- return accountRepository.save(account); } 

JpaRepository::getOne mengembalikan pembungkus atas kunci yang memiliki tipe yang sama dengan "entitas" yang hidup. Kode ini hanya memberikan dua permintaan:


 call next value for hibernate_sequence insert into bank_account (id, /*…*/ user_id) values (/*…*/) 

Saat entitas yang dibuat berisi banyak bidang dengan hubungan banyak-ke-satu / satu-ke-satu, teknik ini akan membantu mempercepat penyimpanan dan mengurangi beban pada basis data.


Menjalankan Query HQL


Ini adalah topik yang terpisah dan menarik :). Model domainnya sama dan ada permintaan seperti itu:


 @Query("select count(ba) " + " from BankAccount ba " + " join ba.user user " + " where user.id = :id") long countUserAccounts(@Param("id") Long id); 

Pertimbangkan HQL "murni":


 select count(ba) from BankAccount ba join ba.user user where user.id = :id 

Ketika dijalankan, query SQL berikut akan dibuat:


 select count(ba.id) from bank_account ba inner join user u on ba.user_id = u.id where u.id = ? 

Masalahnya di sini tidak segera terbukti bahkan oleh kehidupan yang bijaksana dan pengembang SQL yang dipahami dengan baik: inner join dengan kunci pengguna akan mengecualikan akun dengan user_id hilang dari sampel (dan dengan cara yang baik, memasukkan itu harus dilarang di tingkat skema), yang berarti bahwa tidak user_id bergabung dengan tabel user sama sekali perlu. Permintaan dapat disederhanakan (dan dipercepat):


 select count(ba.id) from bank_account ba where ba.user_id = ? 

Ada cara untuk dengan mudah mencapai perilaku ini di c menggunakan HQL:


 @Query("select count(ba) " + " from BankAccount ba " + " where ba.user.id = :id") long countUserAccounts(@Param("id") Long id); 

Metode ini menciptakan permintaan "lite".


Abstrak kueri vs. metode


Salah satu fitur utama dari Spring Data adalah kemampuan untuk membuat kueri dari nama metode, yang sangat nyaman, terutama dalam kombinasi dengan add-on cerdas dari IntelliJ IDEA. Permintaan yang dijelaskan dalam contoh sebelumnya dapat dengan mudah ditulis ulang:


 // @Query("select count(ba) " + " from BankAccount ba " + " where ba.user.id = :id") long countUserAccounts(@Param("id") Long id); // long countByUserAccount_Id(Long id); 

Tampaknya lebih sederhana, dan lebih pendek, dan lebih mudah dibaca, dan yang paling penting - Anda tidak perlu melihat permintaan itu sendiri. Saya membaca nama metode - dan sudah jelas apa yang dipilih dan bagaimana caranya. Tetapi iblis ada di sini dalam perinciannya. Query terakhir untuk metode yang ditandai dengan @Query telah kita lihat. Apa yang akan terjadi pada kasus kedua?


Babah!
 select count(ba.id) from bank_account ba left outer join // <--- !!!!!!! user u on ba.user_id = u.id where u.id = ? 

"Apa-apaan ini !?" - pengembang akan berseru. Bagaimanapun, kita sudah melihat itu pemain biola join tidak diperlukan.


Alasannya biasa-biasa saja:



Jika Anda belum memutakhirkan ke versi yang ditambal, dan bergabung dengan tabel memperlambat kueri di sini dan sekarang, maka jangan putus asa: ada dua cara untuk mengurangi rasa sakit:


  • cara yang baik adalah menambahkan optional = false (jika sirkuit memungkinkan):


     @Entity public class BankAccount { @Id Long id; @ManyToOne @JoinColumn(name = "user_id", optional = false) User user; } 

  • Cara kruk adalah menambahkan kolom dengan tipe yang sama dengan kunci Entitas pengguna dan menggunakannya dalam kueri alih-alih bidang user :


     @Entity public class BankAccount { @Id Long id; @ManyToOne @JoinColumn(name = "user_id") User user; @Column(name = "user_id", insertable = false, updatable = false) Long userId; } 

    Sekarang permintaan-dari-metode akan lebih baik:


     long countByUserId(Long id); 

    memberi


     select count(ba.id) from bank_account ba where ba.user_id = ? 

    apa yang kita capai.



Batas pengambilan sampel


Untuk keperluan kami, kami perlu membatasi pemilihan (misalnya, kami ingin mengembalikan Optional dari metode *RepositoryCustom ):


 select ba.* from bank_account ba order by ba.rate limit ? 

Sekarang Jawa:


 @Override public Optional<BankAccount> findWithHighestRate() { String query = "select b from BankAccount b order by b.rate"; BankAccount account = em .createQuery(query, BankAccount.class) .setFirstResult(0) .setMaxResults(1) .getSingleResult(); return Optional.ofNullable(bankAccount); } 

Kode yang ditentukan memiliki satu fitur yang tidak menyenangkan: jika permintaan mengembalikan pilihan kosong, pengecualian akan dilemparkan


 Caused by: javax.persistence.NoResultException: No entity found for query 

Dalam proyek yang saya lihat, ini diselesaikan dengan dua cara utama:


  • coba-tangkap dengan variasi mulai dari Optonal.empty() pengecualian dan mengembalikan Optonal.empty() ke cara yang lebih maju, seperti mengoper lambda dengan permintaan ke metode utilitas
  • aspek di mana metode repositori dibungkus kembali Optional

Dan sangat jarang, saya melihat solusi yang tepat:


 @Override public Optional<BankAccount> findWithHighestRate() { String query = "select b from BankAccount b order by b.rate"; return em.unwrap(Session.class) .createQuery(query, BankAccount.class) .setFirstResult(0) .setMaxResults(1) .uniqueResultOptional(); } 

EntityManager adalah bagian dari standar JPA, sementara Session adalah milik Hibernate dan merupakan alat IMHO yang lebih canggih, yang sering dilupakan.


[Terkadang] perbaikan yang berbahaya


Saat Anda perlu mendapatkan satu bidang kecil dari entitas "tebal", kami melakukan ini:


 @Query("select a.available from BankAccount a where a.id = :id") boolean findIfAvailable(@Param("id") Long id); 

Permintaan memungkinkan Anda untuk mendapatkan satu bidang tipe boolean tanpa memuat seluruh entitas (dengan penambahan cache tingkat pertama, memeriksa perubahan di akhir sesi, dan biaya lainnya). Kadang-kadang ini tidak hanya tidak meningkatkan kinerja, tetapi sebaliknya - itu menciptakan pertanyaan yang tidak perlu dari awal. Bayangkan sebuah kode yang melakukan beberapa pemeriksaan:


 @Override @Transactional public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new); // ... return repository.findIfAvailable(id); } 

Kode ini membuat setidaknya 2 permintaan, meskipun yang kedua dapat dihindari:


 @Override @Transactional public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new); // ... return repository.findById(id) //    .map(BankAccount::isAvailable) .orElseThrow(IllegalStateException::new); } 

Kesimpulannya sederhana: jangan mengabaikan cache dari level pertama, dalam kerangka satu transaksi, hanya JpaRepository::findById pertama JpaRepository::findById merujuk ke database, JpaRepository::findById cache dari level pertama selalu aktif dan terikat pada sesi, yang biasanya terkait dengan transaksi saat ini.


Tes untuk dimainkan (tautan ke repositori diberikan di awal artikel):


  • tes antarmuka sempit: InterfaceNarrowingTest
  • uji untuk contoh dengan kunci komposit: EntityWithCompositeKeyRepositoryTest
  • uji kelebihan CrudRepository::save : ModifierTest.java
  • blind test CrudRepository::findById : ChildServiceImplTest
  • tes left join tidak perlu: BankAccountControlRepositoryTest

Biaya panggilan tambahan ke CrudRepository::save dapat dihitung menggunakan RedundantSaveBenchmark . Diluncurkan menggunakan kelas BenchmarkRunner .

Source: https://habr.com/ru/post/id441386/


All Articles