جاء الابن الرضيع إلى أبيه
وسأل الطفل
- ما هو جيد
وما هو سيء
فلاديمير ماياكوفسكي
هذه المقالة تدور حول Spring Data JPA ، وبالتحديد في أشعل النار تحت الماء التي قابلتها في طريقي ، وبالطبع عن الأداء.
يمكن تشغيل الأمثلة الموضحة في المقالة في بيئة الاختبار ، ويمكن الوصول إليها بالرجوع إليها .
ملاحظة لأولئك الذين لم ينتقلوا بعد إلى برنامج Spring Boot 2في إصدارات Spring Data JPA 2. * CrudRepository
الواجهة الرئيسية للعمل مع المستودعات ، وهي CrudRepository
، التي ورثت JpaRepository
منها ، JpaRepository
. في الإصدارات 1. * تبدو الطرق الرئيسية هكذا:
public interface CrudRepository<T, ID> { T findOne(ID id); List<T> findAll(Iterable<ID> ids); }
في الإصدارات الجديدة:
public interface CrudRepository<T, ID> { Optional<T> findById(ID id); List<T> findAllById(Iterable<ID> ids); }
لذلك دعونا نبدأ.
حدد t. * من t حيث t.id في (...)
أحد الاستعلامات الأكثر شيوعًا هو الاستعلام عن النموذج "تحديد جميع السجلات التي يقع المفتاح في المجموعة المرسلة". أنا متأكد أن جميعكم تقريباً كتبوا أو رأوا شيئًا كهذا
@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);
هذه طلبات عملية أو مناسبة ، ولا توجد مشكلات في الأداء أو الأداء ، لكن هناك عيبًا صغيرًا وغير واضح تمامًا.
قبل أن تفتح الخطوط ، حاول أن تفكر بنفسك.العيب هو أن الواجهة ضيقة جدًا بحيث لا يمكن نقل المفاتيح. "ماذا في ذلك؟" - أنت تقول. "حسنًا القائمة ، حسنًا ، لا أرى مشكلة هنا." ومع ذلك ، إذا نظرنا إلى أساليب واجهة الجذر التي تأخذ العديد من القيم ، فإننا نرى في كل مكان Iterable
:
"ماذا في ذلك؟ وأريد قائمة. لماذا هي أسوأ؟"
ما هو أسوأ من ذلك ، فقط كن مستعدًا لظهور كود مشابه على مستوى أعلى في طلبك:
public List<BankAccount> findByUserId(List<Long> userIds) { Set<Long> ids = new HashSet<>(userIds); return repository.findByUserIds(ids); }
لا يعمل هذا الكود سوى عكس المجموعات. قد يحدث أن تكون الوسيطة للأسلوب قائمة ، وطريقة المستودع تقبل المجموعة (أو العكس) ، وعليك فقط إعادة استدعاؤها لتمرير التجميع. بالطبع ، لن تصبح هذه مشكلة على خلفية التكاليف العامة للطلب نفسه ، إنها تتعلق بالإيماءات غير الضرورية.
لذلك ، من الممارسات الجيدة استخدام Iterable
:
@Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Iterable<Long> ids);
زد واي إذا كنا نتحدث عن طريقة من *RepositoryCustom
، فمن المنطقي استخدام Collection
لتبسيط حساب الحجم داخل التطبيق:
public interface BankAccountRepositoryCustom { boolean anyMoneyAvailable(Collection<Long> accountIds); } public class BankAccountRepositoryImpl { @Override public boolean anyMoneyAvailable(Collection<Long> accountIds) { if (ids.isEmpty()) return false;
رمز إضافي: مفاتيح غير مكررة
استمرارًا للقسم الأخير ، أريد أن أسترعي الانتباه إلى فكرة خاطئة شائعة:
@Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Set<Long> ids);
مظاهر أخرى لنفس الخطأ:
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);
للوهلة الأولى ، لا شيء غير عادي ، أليس كذلك؟
خذ وقتك ، فكر بنفسك ؛)حدد استعلامات HQL / JPQL للنموذج select t from t where t.field in ...
النهاية إلى استعلام
select b.* from BankAccount b where b.user_id in (?, ?, ?, ?, ?, …)
والتي ستعود دائمًا إلى نفس الشيء بغض النظر عن وجود التكرار في الوسيطة. لذلك ، لضمان تفرد المفاتيح ليست ضرورية. هناك حالة خاصة واحدة - Oracle ، حيث يؤدي الضغط على 1000 مفتاح في إلى حدوث خطأ. ولكن إذا كنت تحاول تقليل عدد المفاتيح عن طريق استبعاد التكرار ، فعليك التفكير في سبب حدوثها. على الأرجح الخطأ هو في مكان ما أعلاه.
حتى في رمز جيد استخدام Iterable
:
@Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Iterable<Long> ids);
ساموبس
ألقِ نظرة فاحصة على هذا الرمز واعثر هنا على ثلاثة عيوب وخطأ واحد محتمل:
@Query("from User u where u.id in :ids") List<User> findAll(@Param("ids") Iterable<Long> ids);
اعتقد بعض أكثر- كل شيء يتم تنفيذه بالفعل في
SimpleJpaRepository::findAllById
- طلب الخمول عند تمرير قائمة فارغة (في
SimpleJpaRepository::findAllById
هناك التحقق المقابلة) - يتم فحص جميع الاستعلامات الموضحة باستخدام
@Query
في مرحلة رفع السياق ، والتي تستغرق وقتًا (على عكس SimpleJpaRepository::findAllById
) - إذا تم استخدام Oracle ، وعندما تكون مجموعة المفاتيح فارغة ، فقد حصلنا على الخطأ
ORA-00936: missing expression
(لن يحدث ذلك عند استخدام SimpleJpaRepository::findAllById
، راجع النقطة 2)
هاري بوتر ومفتاح مركب
ألقِ نظرة على مثالين واختر المثال المفضل لديك:
عدد مرات الأسلوب
@Embeddable public class CompositeKey implements Serializable { Long key1; Long key2; } @Entity public class CompositeKeyEntity { @EmbeddedId CompositeKey key; }
طريقة رقم اثنين
@Embeddable public class CompositeKey implements Serializable { Long key1; Long key2; } @Entity @IdClass(value = CompositeKey.class) public class CompositeKeyEntity { @Id Long key1; @Id Long key2; }
للوهلة الأولى ، لا يوجد فرق. جرب الآن الطريقة الأولى وقم بإجراء اختبار بسيط:
في سجل الاستعلام (يمكنك الاحتفاظ به ، أليس كذلك؟) سنرى هذا:
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 = ?
الآن المثال الثاني
يبدو سجل البحث مختلفًا:
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=?
هذا هو الفرق: في الحالة الأولى ، نحصل دائمًا على طلب واحد ، في الحالة الثانية - الطلبات.
يكمن سبب هذا السلوك في SimpleJpaRepository::findAllById
:
الطريقة الأفضل بالنسبة لك هي تحديدها بناءً على مدى أهمية عدد الطلبات.
CrudRepository اضافية :: حفظ
غالبًا ما يوجد في الكود مضادات مثل:
@Transactional public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return repo.save(account); }
القارئ في حيرة: أين هو المضاد؟ يبدو هذا الرمز منطقيًا جدًا: نحصل على الكيان - التحديث - الحفظ. كل شيء يشبه في أفضل بيوت سانت بطرسبرغ. أجرؤ على القول إن استدعاء CrudRepository::save
لا لزوم له هنا.
أولاً: أسلوب updateRate
معاملات ، لذلك ، يتم تتبع جميع التغييرات في الكيان المدار بواسطة السبات وتتحول إلى طلب عندما Session::flush
تنفيذ Session::flush
، والذي يحدث في هذا الرمز عندما تنتهي هذه الطريقة.
ثانياً ، CrudRepository::save
نلقي نظرة على طريقة CrudRepository::save
. كما تعلم ، تعتمد جميع المستودعات على SimpleJpaRepository
. هنا هو تنفيذ 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); } }
هناك دقة لا يتذكرها الجميع: السبات يعمل من خلال الأحداث. بمعنى آخر ، ينشئ كل إجراء مستخدم حدثًا في قائمة الانتظار ومعالجته مع مراعاة الأحداث الأخرى في قائمة الانتظار نفسها. في هذه الحالة ، EntityManager::merge
استدعاء EntityManager::merge
MergeEvent
، والذي تتم معالجته افتراضيًا في DefaultMergeEventListener::onMerge
. أنه يحتوي على منطق متفرع إلى حد ما ، ولكن بسيط لكل حالة من حجة الكيان. في حالتنا ، يتم الحصول على الكيان من المستودع داخل طريقة المعاملة وهو في حالة PERSISTENT (بمعنى أنه يتحكم فيه إطار العمل بشكل أساسي):
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);
الشيطان موجود في التفاصيل ، أي في الأساليب DefaultMergeEventListener::cascadeOnMerge
و DefaultMergeEventListener::copyValues
. دعنا نستمع إلى الخطاب المباشر لفلاد ميخالتشي ، أحد المطورين الرئيسيين في سبات:
في استدعاء الأسلوب copyValues ، يتم نسخ الحالة المائية مرة أخرى ، لذلك يتم إنشاء صفيف جديد بشكل متكرر ، وبالتالي إهدار دورات وحدة المعالجة المركزية. إذا كان لدى الكيان ارتباطات تابعة وتمت تتابع عملية الدمج أيضًا من كيان إلى كيان تابع ، يكون مقدار الحمل أكبر لأن كل كيان تابع سوف ينشر MergeEvent وتستمر الدورة.
وبعبارة أخرى ، يجري العمل الذي لا يمكنك القيام به. نتيجة لذلك ، يمكن تبسيط التعليمات البرمجية الخاصة بنا مع تحسين أدائها:
@Transactional public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return account; }
بالطبع ، من غير المريح أن تضع ذلك في الاعتبار عند تطوير كود شخص ما JpaRepository::save
، لذلك نود إجراء تغييرات على مستوى الإطار السلكي حتى تفقد طريقة JpaRepository::save
خصائصها الضارة. هل هذا ممكن؟
ومع ذلك ، فإن القارئ المتطور ربما شعر بالفعل بوجود شيء خاطئ. في الواقع ، لن يكسر هذا التغيير أي شيء ، ولكن فقط في الحالة البسيطة عندما لا توجد كيانات فرعية:
@Entity public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO; }
لنفترض الآن أن صاحبها مرتبط بالحساب:
@Entity public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO; @ManyToOne @JoinColumn(name = "user_id") User user; }
هناك طريقة تسمح لك بفصل المستخدم عن الحساب ونقله إلى المستخدم الجديد:
@Transactional public BankAccount changeUser(Long id, User newUser) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setUser(newUser); return repo.save(account); }
ماذا سيحدث الان؟ التحقق من em.contains(entity)
سيعود إلى true ، مما يعني em.merge(entity)
لن يتم استدعاء em.merge(entity)
. إذا تم إنشاء مفتاح كيان User
على أساس التسلسل (واحدة من الحالات الأكثر شيوعًا) ، فلن يتم إنشاؤه حتى يتم إكمال المعاملة (أو Session::flush
استدعاء Session::flush
يدويًا) ، أي سيكون المستخدم في حالة DETACHED ، والكيان الأصل ( حساب) - في الحالة الثابتة. في بعض الحالات ، يمكن أن يؤدي هذا إلى كسر منطق التطبيق ، وهو ما حدث:
02/03/2018 DATAJPA-931 فواصل دمج مع RepositoryItemWriter
في هذا الصدد ، تم إنشاء مهمة Revert لتحسينات للكيانات الموجودة في CrudRepository :: save وتم إجراء التغييرات: Revert DATAJPA-931 .
Blind CrudRepository :: findById
نواصل النظر في نموذج البيانات نفسه:
@Entity public class User { @Id Long id;
يحتوي التطبيق على طريقة تنشئ حسابًا جديدًا للمستخدم المحدد:
@Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); userRepository.findById(userId).ifPresent(account::setUser);
مع الإصدار 2. * لم يكن الضرب المضاد المشار إليه بالسهم مدهشًا - يظهر بشكل أكثر وضوحًا في الإصدارات الأقدم:
@Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.findOne(userId));
إذا لم تشاهد العيب \ "بالعين \" ، فقم بإلقاء نظرة على الاستفسارات: 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 ()
الطلب الأول نحصل على المستخدم عن طريق المفتاح. بعد ذلك ، نحصل على مفتاح حساب الوليد من قاعدة البيانات وإدراجه في الجدول. والشيء الوحيد الذي نأخذه من المستخدم هو المفتاح ، والذي لدينا بالفعل كوسيطة أسلوب. من ناحية أخرى ، يحتوي BankAccount
على حقل "المستخدم" ولا يمكننا تركه فارغًا (كأشخاص محترمين نضع قيودًا في المخطط). المطورين ذوي الخبرة ربما نرى بالفعل وسيلة وأكل السمك ، وركوب الخيل الحصول على كل من المستخدم والطلب لا:
@Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.getOne(userId));
JpaRepository::getOne
بإرجاع غلاف عبر المفتاح الذي له نفس النوع مثل "الكيان" الحي. يعطي هذا الرمز طلبين فقط:
call next value for hibernate_sequence insert into bank_account (id, user_id) values ()
عندما يحتوي الكيان الجاري إنشاؤه على العديد من الحقول ذات العلاقة من واحد إلى واحد / واحد إلى واحد ، فإن هذه التقنية سوف تساعد في تسريع الادخار وتقليل الحمل في قاعدة البيانات.
تنفيذ استفسارات HQL
هذا هو موضوع منفصل ومثيرة للاهتمام :). نموذج المجال هو نفسه وهناك مثل هذا الطلب:
@Query("select count(ba) " + " from BankAccount ba " + " join ba.user user " + " where user.id = :id") long countUserAccounts(@Param("id") Long id);
النظر في HQL "النقي":
select count(ba) from BankAccount ba join ba.user user where user.id = :id
عند تنفيذه ، سيتم إنشاء استعلام SQL التالي:
select count(ba.id) from bank_account ba inner join user u on ba.user_id = u.id where u.id = ?
المشكلة هنا ليست واضحة على الفور حتى من خلال الحياة الحكيمة والفهم الجيد لمطوري SQL: سوف يستبعد inner join
بمفتاح المستخدم الحسابات التي تفتقد user_id
من التحديد (وبطريقة جيدة ، يجب حظر إدراج تلك على مستوى المخطط) ، مما يعني أنه ليس من user_id
الانضمام إلى جدول user
على الإطلاق بحاجة إلى. يمكن تبسيط الطلب (وتسريعه):
select count(ba.id) from bank_account ba where ba.user_id = ?
هناك طريقة لتحقيق هذا السلوك بسهولة في c باستخدام HQL:
@Query("select count(ba) " + " from BankAccount ba " + " where ba.user.id = :id") long countUserAccounts(@Param("id") Long id);
تقوم هذه الطريقة بإنشاء طلب "لايت".
الاستعلام مقابل الطريقة المجردة
تتمثل إحدى الميزات الرئيسية لـ Spring Data في القدرة على إنشاء استعلام من اسم الطريقة ، وهو مناسب للغاية ، خاصةً مع الوظيفة الإضافية الذكية من IntelliJ IDEA. يمكن إعادة كتابة الاستعلام الموضح في المثال السابق بسهولة:
يبدو أنه أبسط وأقصر وأكثر قابلية للقراءة ، والأهم من ذلك - لا تحتاج إلى إلقاء نظرة على الطلب نفسه. قرأت اسم الطريقة - ومن الواضح بالفعل ما تختاره وكيف. لكن الشيطان هنا في التفاصيل. الاستعلام النهائي عن الطريقة التي تم وضع علامة عليها في @Query
التي شاهدناها بالفعل. ماذا سيحدث في الحالة الثانية؟
باباه! select count(ba.id) from bank_account ba left outer join // <
"ماذا بحق الجحيم!" - سوف يصيح المطور. بعد كل شيء ، لقد رأينا ذلك بالفعل عازف الكمان join
ليست هناك حاجة.
والسبب هو prosaic:
إذا لم تكن قد قمت بالترقية إلى الإصدارات المصححة حتى الآن ، ويؤدي الانضمام إلى الجدول إلى إبطاء الطلب هنا والآن ، فلا تقلق: هناك طريقتان لتخفيف الألم:
هناك طريقة جيدة لإضافة optional = false
(إذا سمحت الدائرة):
@Entity public class BankAccount { @Id Long id; @ManyToOne @JoinColumn(name = "user_id", optional = false) User user; }
طريقة عكاز هي إضافة عمود من نفس النوع كمفتاح كيان User
واستخدامه في الاستعلامات بدلاً من حقل 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; }
الآن سيكون الطلب من الطريقة أجمل:
long countByUserId(Long id);
يعطي
select count(ba.id) from bank_account ba where ba.user_id = ?
ماذا حققنا؟
حد أخذ العينات
لأغراضنا ، نحتاج إلى تقييد التحديد (على سبيل المثال ، نريد إرجاع Optional
من طريقة *RepositoryCustom
):
select ba.* from bank_account ba order by ba.rate limit ?
الآن جافا:
@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); }
يحتوي الرمز المحدد على ميزة واحدة غير سارة: في حالة إرجاع الطلب تحديدًا فارغًا ، سيتم طرح استثناء
Caused by: javax.persistence.NoResultException: No entity found for query
في المشاريع التي رأيتها ، تم حل هذا بطريقتين رئيسيتين:
- جرّب الاختلافات مع الاختلافات بين الاستثناء بشكل صريح وإعادة
Optonal.empty()
إلى أساليب أكثر تقدمًا ، مثل تمرير lambda مع طلب إلى أداة مساعدة - الجانب الذي طرق ملف التخزين ملفوفة العودة
Optional
ونادراً ما رأيت الحل الصحيح:
@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
جزءًا من معيار JPA ، بينما تنتمي Session
إلى Hibernate وهي IMHO أداة أكثر تقدمًا ، وغالبًا ما يتم نسيانها.
[في بعض الأحيان] التحسن الضار
عندما تحتاج إلى الحصول على حقل صغير واحد من كيان "كثيف" ، فإننا نفعل ذلك:
@Query("select a.available from BankAccount a where a.id = :id") boolean findIfAvailable(@Param("id") Long id);
يتيح لك الطلب الحصول على حقل واحد من النوع boolean
دون تحميل الكيان بأكمله (مع إضافة ذاكرة التخزين المؤقت من المستوى الأول ، والتحقق من التغييرات في نهاية الجلسة ، والنفقات الأخرى). في بعض الأحيان ، لا يؤدي هذا إلى تحسين الأداء فحسب ، بل إنه يعمل أيضًا على إنشاء استعلامات غير ضرورية من البداية. تخيل رمزًا يقوم ببعض الاختبارات:
@Override @Transactional public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new);
يقدم هذا الرمز طلبين على الأقل ، رغم أنه يمكن تجنب الثاني:
@Override @Transactional public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new);
الاستنتاج بسيط: لا تهمل ذاكرة التخزين المؤقت للمستوى الأول ، في إطار معاملة واحدة ، يشير JpaRepository::findById
الأول فقط إلى قاعدة البيانات ، JpaRepository::findById
ذاكرة التخزين المؤقت للمستوى الأول هي دائما قيد التشغيل وترتبط بجلسة ، والتي عادة ما تكون مرتبطة بالمعاملة الحالية.
اختبارات للعب بها (يرد الرابط إلى المستودع في بداية المقال):
- اختبار واجهة ضيقة:
InterfaceNarrowingTest
- اختبار للحصول على مثال باستخدام مفتاح مركب:
EntityWithCompositeKeyRepositoryTest
- اختبار فائض
CrudRepository::save
: ModifierTest.java
- اختبار أعمى
CrudRepository::findById
: ChildServiceImplTest
- اختبار
left join
غير الضروري: BankAccountControlRepositoryTest
يمكن حساب تكلفة مكالمة إضافية إلى CrudRepository::save
باستخدام RedundantSaveBenchmark
. يتم إطلاقه باستخدام فئة BenchmarkRunner
.