پرش به محتوا
خانه » بلاگ » آیا ما واقعا به Hibernate نیاز داریم؟ پروژه JOOQ

آیا ما واقعا به Hibernate نیاز داریم؟ پروژه JOOQ

همه پروژه‌های Backend برای کار با پایگاه داده به یک لایه دسترسی به داده یا Data Access Layer نیاز دارند.

در دنیای Java هم ابزارهای مختلفی برای این کار وجود دارد از جمله Spring Data، Hibernate، jOOQ و خود JDBC.

خیلی از توسعه‌دهنده‌ها فکر می‌کنند باید بین این ابزارها یکی را انتخاب کنند، در حالی که این‌ها رقیب هم نیستند و هر کدام در یک لایه متفاوت قرار می‌گیرند.

به صورت پیش‌فرض در پروژه‌های Spring Boot ترکیب Spring Data JPA به همراه Hibernate فعال است و اکثر توسعه‌دهندگان همین حالت پیش‌فرض را انتخاب می‌کنند و بعداً طبق نیاز پروژه عوض می‌کنند، چون هر ابزار کاربرد خودش را دارد.

برای اینکه ببینید این ابزارها واقعاً چه Query ای به دیتابیس می زنند، می‌توان از تنظیمات زیر رو به application.properties اضافه کرد:

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

جایگاه هر کدام کجاست؟

قبل از هر چیز بهتر است بدانیم هر کدام از این ابزارها در کجای مسیر رسیدن به دیتابیس قرار می‌گیرند:

دو مسیر موازی برای رسیدن به دیتابیس: یکی از Spring Data JPA و Hibernate، دیگری مستقیم از jOOQ
دو مسیر موازی برای رسیدن به دیتابیس: یکی از Spring Data JPA و Hibernate، دیگری مستقیم از jOOQ

همان‌طور که می‌بینید دو مسیر موازی برای رسیدن به دیتابیس وجود دارد. مسیر اول از Spring Data JPA و Hibernate عبور می‌کند و مسیر دوم مستقیم از jOOQ به JDBC می‌رسد.

ویژگی های Spring Data JPA

  1. یک لایه Abstraction روی JPA است و خودش ORM نیست
  2. ساخت خودکار Query از روی نام متد یا همان Derived Query
  3. پشتیبانی آماده از Pagination و Sorting
  4. پشتیبانی از Auditing و مدیریت Transaction
  5. داشتن ماژول‌های جداگانه برای JDBC، MongoDB، Redis و R2DBC
public interface OrderRepository extends JpaRepository<Order, Long> {
 
    List<Order> findByStatusAndCreatedAtAfter(OrderStatus s, Instant from);
 
    Page<Order> findByCustomerId(Long customerId, Pageable pageable);
}
در Spring Data JPA خودِ نام متد، Query را می‌سازد
در Spring Data JPA خودِ نام متد، Query را می‌سازد

نکته: وقتی اJpaRepository استفاده می‌کنید در واقع دارید بHibernate کار می‌کنید. Spring Data JPA فقط یک لایه نازک است که پشت صحنEntityManager را صدا می‌زند. پس رفتارهایی مثل Lazy Loading و مشکل N+1 مربوط به Hibernate است، نه Spring Data.

ویژگی های Hibernate

  1. پشتیبانی ازPersistence Context Dirty Checking
  2. داشتن Cache سطح اول در هر Transaction
  3. پشتیبانی ازLazy Loading و Cascade
  4. پشتیبانی ازOptimistic Locking با @Version
  5. تولید ترتیب درسINSERT ها بر اساس Foreign Key

مهم‌ترین ویژگHibernate همان Dirty Checking است. یعنی اگر مقدار یEntity را داخTransaction تغییر دهید، خودUPDATE لازم را می‌سازد و نیازی به صدا زدن save نیست:

@Transactional
public void applyDiscount(Long orderId) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.setTotal(order.getTotal().multiply(new BigDecimal("0.9")));
}

محدودیت های Hibernate

  1. مشکل N+1 Query هنگام خواندن Relation ها
  2. خطای LazyInitializationException خارج از Transaction
  3. خطای MultipleBagFetchException هنگام Fetch همزمان دو List
  4. ضعف در گزارش‌گیری و پشتیبانی نکردن از Window Function و CTE
  5. خواندن کل Entity ها برای انجام عملیات گروهی
مشکل N+1 و ساده‌ترین راه برطرف کردن آن
مشکل N+1 و ساده‌ترین راه برطرف کردن آن

برای آشنایی بهتر با مشکل N+1 Query می‌توانید از این لینک استفاده کنید.

یک راه‌حل ساده قبل از تغییر ابزار: خیلی از این کندی‌ها با Projection حل می‌شود. Projection یعنی به جای خواندن کل Entity، فقط ستون‌هایی را بخوانید که واقعاً لازم دارید. کافی است یک Interface یا Record با همان چند فیلد بسازید و آن را به عنوان خروجی متد Repository برگردانید. در این حالت Hibernate فقط همان ستون‌ها را در SELECT می‌آورد، Entity وارد Persistence Context نمی‌شود و Snapshot ای هم برای Dirty Checking ساخته نمی‌شود:

public interface OrderSummary {
    Long getId();
    String getCustomerName();
    BigDecimal getTotal();
}
 
List<OrderSummary> findByStatus(OrderStatus status);

نکته: Hibernate برای عملیات نوشتن (Write) خیلی خوب است ولی برای read query ها پیچیده ضعیف عمل می‌کند.

تفاوت خواندن کل Entity با خواندن یک Projection
تفاوت خواندن کل Entity با خواندن یک Projection

تا اینجا دیدیم که Hibernate برای نوشتن عالی است ولی هر جا کار به SQL جدی می‌رسد کم می‌آورد. اینجا دقیقاً همان نقطه‌ای است که jOOQ وارد می‌شود.

چرا JAVA OBJECT ORIENTED QUERY (JOOQ)

چطور کار می کند
چطور کار می کند

برای شروع باید بگیم که JOOQ مخفف Java Object Oriented Query است.

تفاوت دیدگاه این دو ابزار در یک جمله خلاصه می‌شود: در ORM شما مدل جاوا را می‌نویسید و ابزار SQL را می‌سازد، اما در jOOQ شما SQL را می‌نویسید و ابزار فقط مطمئن می‌شود که درست نوشته‌اید.

خالق jOOQ آقای Lukas Eder است بنیان‌گذار شرکت Data Geekery در سوئیس که از سال ۲۰۰۹ روی این پروژه کار می‌کند. فلسفهٔ کار او در یک جملهٔ معروف خلاصه شده است: «SQL was never meant to be anything other than… SQL!» یعنی SQL خودش یک زبان کامل و رسا است و قرار نبود پشت لایه‌های Mapping پنهان شود.

خودِ تیم jOOQ هشت دلیل اصلی برای استفاده از آن مطرح می‌کند که خلاصه‌شان این است.

  1. Database First: در ORM ها معمولاً مدل جاوا شکل دیتابیس را تعیین می‌کند. در jOOQ برعکس است؛ Schema دیتابیس منبع اصلی حقیقت است و کد جاوا از روی آن ساخته می‌شود. مهم‌ترین دارایی شما داده‌هایتان است، نه کلاس‌هایتان.
  2. Typesafe SQL: خطای Syntax در SQL به جای اینکه در Production خودش را نشان دهد، همان موقع در Compiler جاوا مشخص می‌شود. jOOQ زبان SQL را به شکل یک DSL داخل جاوا مدل کرده و Type داده‌ها را هم بررسی می‌کند.
  3. Code Generation: کلاس‌های جاوا از روی Metadata دیتابیس ساخته می‌شوند. اگر کسی نام یک جدول یا ستون را عوض کند، پروژه در زمان Build خطا می‌دهد و لازم نیست دستی دنبال جاهای استفاده‌شده بگردید.
  4. Active Records: برای CRUD ساده لازم نیست همهٔ SQL را دستی بنویسید. jOOQ رکوردهای تولیدشده را به صورت Active Record در اختیارتان می‌گذارد و Mapping به POJO را هم انجام می‌دهد.
  5. Multi-Tenancy: اگر پروژه‌تان چند Schema یا چند Tenant دارد، می‌توانید نام Schema و جدول‌ها را در زمان Runtime تغییر دهید. Row-Level Security هم پشتیبانی می‌شود.
  6. Standardisation: تفاوت‌های ریز بین Dialect های مختلف SQL را خود jOOQ مدیریت می‌کند. یک بار Query را می‌نویسید و jOOQ آن را به نزدیک‌ترین معادل در دیتابیس مقصد تبدیل می‌کند.
  7. Query Lifecycle: برخلاف ORM ها که تولید SQL در آن‌ها یک جعبهٔ سیاه است، در jOOQ می‌توانید در مراحل ساخت Query هوک بگذارید؛ برای Logging، مدیریت Transaction، تولید ID و تغییر دادن SQL.
  8. Stored Procedures: خیلی از ORM ها با Stored Procedure میانهٔ خوبی ندارند. jOOQ اجازه می‌دهد فراخوانی Stored Function را مستقیم داخل Statement های خودتان قرار دهید.

این هشت مورد برگرفته از دلایلی است که در سایت رسمی jOOQ مطرح شده و می‌توانید توضیحات کامل‌تر را در jooq.org ببینید.

هشت دلیل اصلی استفاده از jOOQ در یک نگاه
هشت دلیل اصلی استفاده از jOOQ در یک نگاه

ویژگی ساختاری ‍JOOQ

  1. پشتیبانی کامل از Window Function، CTE و امکانات خاص هر دیتابیس
  2. پشتیبانی از MULTISET برای خواندن داده‌های تودرتو در یک Query
  3. مناسب برای Query داینامیک، گزارش‌گیری و عملیات گروهی
  4. عدم پشتیبانی از Dirty Checking، Cache و Lazy Loading
  5. رایگان برای دیتابیس‌های Open Source و نیازمند License تجاری برای Oracle و SQL Server

از بین این‌ها ویژگی MULTISET به تنهایی می‌تواند دلیل خوبی برای استفاده از jOOQ باشد، چون داده‌های تودرتو را در یک Query می‌خواند و دیگر خبری از N+1 نیست:

dsl.select(
       ORDERS.ID,
       ORDERS.TOTAL,
       multiset(
           select(ITEMS.SKU, ITEMS.QTY)
           .from(ITEMS)
           .where(ITEMS.ORDER_ID.eq(ORDERS.ID))
       ).convertFrom(r -> r.map(Records.mapping(ItemDto::new)))
   )
   .from(ORDERS)
   .fetch(Records.mapping(OrderDto::new));

توجه کنید: قبل از هر تصمیمی، اول موضوع License را بررسی کنید. اگر پروژهٔ شما روی Oracle یا SQL Server اجرا می‌شود، jOOQ رایگان نیست و این اولین سؤالی است که باید بپرسید، نه آخرین.

استفاده همزمان Hibernate و JOOQ

لازم نیست حتماً یکی از این دو را انتخاب کنید. رایج‌ترین معماری در پروژه‌های امروزی، تقسیم کار بین این دو است:

  1. سمت نوشتن یا Command: استفاده از Spring Data JPA و Hibernate برای Transaction، Cascade و Optimistic Locking
  2. سمت خواندن یا Query: استفاده از jOOQ برای لیست‌ها، گزارش‌ها، Dashboard و جست‌وجوی داینامیک
تقسیم کار بین Hibernate و jOOQ: یکی برای نوشتن، دیگری برای خواندن
تقسیم کار بین Hibernate و jOOQ: یکی برای نوشتن، دیگری برای خواندن

هر دو روی یک DataSource و داخل یک Transaction کار می‌کنند. Spring Boot با اضافه کردن spring-boot-starter-jooq خودش DSLContext را با Transaction های Spring هماهنگ می‌کند.

اما یک نکتهٔ مهم در این ترکیب وجود دارد: Hibernate و jOOQ از تغییرات همدیگر خبر ندارند. پس قبل از اجرای Query با jOOQ باید flush کنید و بعد از تغییر داده با jOOQ باید Persistence Context را پاک کنید:

@Transactional
public void process(Long id) {
    Order order = orderRepository.findById(id).orElseThrow();
    order.markPaid();
 
    entityManager.flush();   // وگرنه jOOQ داده قدیمی می بیند
 
    dsl.update(ORDERS).set(ORDERS.STATUS, "SHIPPED").where(...).execute();
 
    entityManager.clear();   // persistence context الان stale است
}

فراموش کردن این دو خط، منبع باگ‌هایی است که پیدا کردنشان خیلی سخت است.

اگر می‌خواهید این ترکیب را در عمل ببینید، ارائه Simon Martinelli با عنوان Do You Really Need Hibernate؟ در کنفرانس JAVAONE خیلی شروع خوبی من خودم که دوبار دیدم واقعا خوب بود. آقای مارتینلی عضو Java Champion است که سال‌هاست درباره jOOQ می‌نویسد و سخنرانی می‌کند. در این ارائه با یک برنامه نمونه نشان می‌دهد که چطور SQL خالص به کمک jOOQ و Java Record ها کار با داده را ساده می‌کند و مشکل N+1 از بین می‌رود، و در انتها همین ترکیب jOOQ با JPA را بررسی می‌کند.

کد تمام مثال‌های آن ارائه در github.com/simasch/jooq-examples در دسترس است و برای شروع کار خیلی کمک می‌کند.

چند نکته مهم و از روی تجربی و مواردی از مستندات JOOQ

  1. مقدار spring.jpa.open-in-view در Spring Boot به صورت پیش‌فرض true است و Session را تا لایه View باز نگه می‌دارد. بهتر است آن را false کنید تا Query های ناخواسته اجرا نشود.
  2. برای متدهای فقط خواندنی از Transactional(readOnly = true)@ استفاده کنید تا Hibernate کار Dirty Checking را انجام ندهد.
  3. اگر هنوز نمی‌خواهید سراغ jOOQ بروید، این رو تنظیمات رو ست کنید hibernate.default_batch_fetch_size یا `BatchSize@` مشکل N+1 را تا حد زیادی کم می‌کند.
  4. برای Insert انبوه، تنظیم hibernate.jdbc.batch_size را فعال کنید. دقت کنید اگر Generator کلید اصلی از نوع IDENTITY باشد، Hibernate اصلاً نمی‌تواند Batch کند و باید از SEQUENCE استفاده کنید.
  5. هیچ وقت Entity را مستقیم از Controller برنگردانید. هم Lazy Proxy موقع Serialize کردن خطا می‌دهد و هم مدل دیتابیس به قرارداد API تبدیل می‌شود. اینجا هم جواب همان Projection یا DTO است.
  6. Code Generation در jOOQ را از روی Migration های Flyway یا Liquibase و با Testcontainers بسازید، نه از روی دیتابیس لوکال یک نفر.
  7. اگر مدل شما گراف پیچیده و Lazy Loading نمی‌خواهد، Spring Data JDBC یک گزینهٔ میانی و ساده است.
  8. قبل از هر مهاجرتی اول اندازه بگیرید. با datasource-proxy یا p6spy تعداد Query های هر Endpoint را ببینید.

خلاصه جدول

خلاصه jooq
خلاصه jooq

جمع بندی

Spring Data یک لایه راحتی روی JPA است، Hibernate موتور مدیریت وضعیت Entity هاست و jOOQ ابزار کسی است که می‌خواهد خودش SQL بنویسد اما Compiler هوایش را داشته باشد.

ما هم دقیقا به همین دلیل سراغ jOOQ رفتیم. نه به این خاطر که Hibernate ابزار بدی است، بلکه به این خاطر که برای گزارش‌گیری و Query های سنگین از اول ساخته نشده بود. وقتی دیتابیس را به عنوان منبع اصلی پروژه بپذیرید، jOOQ طبیعی‌ترین ابزاری است که می‌توانید انتخاب کنید.

پس سوال درست این نیست که کدام یکی بهتر است، سوال درست این است که هر کدام کجا استفاده شوند.

منبع:

jooq.org

Do You Really Need Hibernate?youtube.com/watch?v=Vd0BNA6m3Kw

Simon Martinelli Blog: martinelli.ch