برنامه نویسی

تست های یکپارچه سازی برای مشکل N + 1 در جاوا

مشکل N + 1 یک مسئله رایج در بسیاری از پروژه های سازمانی است. بدترین چیز این است که تا زمانی که حجم داده ها زیاد نشود متوجه آن نمی شوید. متأسفانه، ممکن است کد به مرحله ای برسد که مقابله با مشکل N + 1 تبدیل به یک کار غیرقابل تحمل شود.

در این مقاله به شما می گویم:

  1. چگونه به طور خودکار مشکل N + 1 را ردیابی کنیم؟
  2. چگونه یک آزمایش بنویسیم تا بررسی کنیم که تعداد پرس و جو از مقدار مورد انتظار تجاوز نمی کند؟

پشته فناوری شامل جاوا، Spring Boot، Spring Data JPA و PostgreSQL است. می توانید مخزن را با نمونه کد از این لینک بررسی کنید.

هیچ محدودیتی برای اعمال Spring Boot یا Hibernate به طور خاص وجود ندارد. اگر با آن تعامل دارید javax.sql.DataSource در پایگاه کد خود، سپس راه حل به شما کمک خواهد کرد. حتی اگر اصلاً از Spring استفاده نمی کنید.

جلد مقاله میم

مثالی از مسئله N + 1

فرض کنید ما روی اپلیکیشنی کار می کنیم که باغ وحش ها را مدیریت می کند. در این مورد، دو نهاد اصلی وجود دارد: Zoo و Animal. به قطعه کد زیر نگاه کنید:

@Entity
@Table(name = "zoo")
public class Zoo {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    private String name;

    @OneToMany(mappedBy = "zoo", cascade = PERSIST)
    private List<Animal> animals = new ArrayList<>();
}

@Entity
@Table(name = "animal")
public class Animal {
    @Id
    @GeneratedValue(strategy = IDENTITY)
    private Long id;

    @ManyToOne(fetch = LAZY)
    @JoinColumn(name = "zoo_id")
    private Zoo zoo;

    private String name;
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

اکنون می خواهیم تمام باغ وحش های موجود را با حیواناتشان بازیابی کنیم. به کد نگاه کنید ZooService کد زیر

@Service
@RequiredArgsConstructor
public class ZooService {
    private final ZooRepository zooRepository;

    @Transactional(readOnly = true)
    public List<ZooResponse> findAllZoos() {
        final var zoos = zooRepository.findAll();
        return zoos.stream()
                   .map(ZooResponse::new)
                   .toList();
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

همچنین، ما می خواهیم بررسی کنیم که همه چیز به خوبی کار می کند. بنابراین، در اینجا یک تست ادغام ساده وجود دارد:

@DataJpaTest
@AutoConfigureTestDatabase(replace = NONE)
@Transactional(propagation = NOT_SUPPORTED)
@Testcontainers
@Import(ZooService.class)
class ZooServiceTest {
    @Container
    static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:13");

    @DynamicPropertySource
    static void setProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
        registry.add("spring.datasource.username", POSTGRES::getUsername);
        registry.add("spring.datasource.password", POSTGRES::getPassword);
    }

    @Autowired
    private ZooService zooService;
    @Autowired
    private ZooRepository zooRepository;

    @Test
    void shouldReturnAllZoos() {
        /* data initialization... */
        zooRepository.saveAll(List.of(zoo1, zoo2));

        final var allZoos = assertQueryCount(
            () -> zooService.findAllZoos(),
            ofSelects(1)
        );

        /* assertions... */
        assertThat(
            ...
        );
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

من به خاطر سادگی، از بخش های اولیه داده و ادعاها صرف نظر کردم. آنها برای موضوع مقاله مهم نیستند. به هر حال، می توانید کل مجموعه تست را از طریق این لینک مشاهده کنید.

من یک قطعه خاص در مورد آزمایش لایه داده در برنامه Spring Boot با Testcontainers دارم. اگر با موضوع آشنایی ندارید، حتما باید آن را بررسی کنید.

آزمون با موفقیت می گذرد. با این حال، اگر عبارات SQL را وارد کنید، متوجه چیزی خواهید شد که ممکن است شما را نگران کند. به خروجی زیر نگاه کنید:

-- selecting all zoos
select z1_0.id,z1_0.name from zoo z1_0
-- selecting animals for the first zoo
select a1_0.zoo_id,a1_0.id,a1_0.name from animal a1_0 where a1_0.zoo_id=?
-- selecting animals for the second zoo
select a1_0.zoo_id,a1_0.id,a1_0.name from animal a1_0 where a1_0.zoo_id=?
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

همانطور که می بینید، ما یک جداگانه داریم select پرس و جو برای هر هدیه Zoo. تعداد کل درخواست ها برابر است با تعداد باغ وحش های انتخابی + 1. بنابراین، این مشکل N + 1 است.

این ممکن است باعث جریمه های عملکرد مهم شود. به خصوص در مقیاس بزرگ داده.

ردیابی مشکل N + 1 به طور خودکار

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

یک کتابخانه جالب به نام datasource-proxy وجود دارد. این یک API مناسب برای بسته بندی فراهم می کند javax.sql.DataSource رابط با یک پروکسی حاوی منطق خاص. برای مثال، می‌توانیم تماس‌هایی را که قبل و بعد از اجرای کوئری فراخوانی شده‌اند، ثبت کنیم. نکته جالب این است که این کتابخانه همچنین حاوی راه حل های خارج از جعبه برای شمارش پرس و جوهای اجرا شده است. ما آن را کمی تغییر می دهیم تا نیازهایمان را برآورده کند.

خدمات شمارش پرس و جو

ابتدا کتابخانه را به وابستگی ها اضافه کنید:

implementation "net.ttddyy:datasource-proxy:1.8"
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

حال ایجاد کنید QueryCountService. این سینگلتون است که تعداد فعلی کوئری های اجرا شده را نگه می دارد و به شما امکان می دهد آن را پاک کنید. به قطعه کد زیر نگاه کنید.

@UtilityClass
public class QueryCountService {
    static final SingleQueryCountHolder QUERY_COUNT_HOLDER = new SingleQueryCountHolder();

    public static void clear() {
        final var map = QUERY_COUNT_HOLDER.getQueryCountMap();
        map.putIfAbsent(keyName(map), new QueryCount());
    }

    public static QueryCount get() {
        final var map = QUERY_COUNT_HOLDER.getQueryCountMap();
        return ofNullable(map.get(keyName(map))).orElseThrow();
    }

    private static String keyName(Map<String, QueryCount> map) {
        if (map.size() == 1) {
            return map.entrySet()
                       .stream()
                       .findFirst()
                       .orElseThrow()
                       .getKey();
        }
        throw new IllegalArgumentException("Query counts map should consists of one key: " + map);
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

در آن صورت، فرض می کنیم که یک واحد وجود دارد DataSource در برنامه ما به همین دلیل است keyName تابع در غیر این صورت یک استثنا ایجاد می کند. با این حال، کد با استفاده از چندین منبع داده تفاوت چندانی نخواهد داشت.

این SingleQueryCountHolder همه را ذخیره می کند QueryCount اشیاء به صورت منظم ConcurrentHashMap.

برعکس، ThreadQueryCountHolder مقادیر را در ThreadLocal هدف – شی. ولی SingleQueryCountHolder برای پرونده ما کافی است

API دو روش ارائه می دهد. این get متد مقدار فعلی پرس و جوهای اجرا شده را در حالی که the clear یک تعداد را صفر می کند.

پروکسی BeanPostProccessor و DataSource

حالا باید ثبت نام کنیم QueryCountService برای جمع آوری داده ها از DataSource. در این صورت، رابط BeanPostProcessor مفید است. به مثال کد زیر نگاه کنید.

@TestComponent
public class DatasourceProxyBeanPostProcessor implements BeanPostProcessor {
    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) {
        if (bean instanceof DataSource dataSource) {
            return ProxyDataSourceBuilder.create(dataSource)
                       .countQuery(QUERY_COUNT_HOLDER)
                       .build();
        }
        return bean;
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

کلاس را با علامت گذاری می کنم @TestComponent حاشیه نویسی و قرار دادن آن در src/test دایرکتوری چون من نیازی به شمارش پرس و جوها خارج از محدوده آزمون ندارم.

همانطور که می بینید، ایده بی اهمیت است. اگر لوبیا باشد DataSource، سپس آن را با ProxyDataSourceBuilder و قرار دهید QUERY_COUNT_HOLDER ارزش به عنوان QueryCountStrategy.

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

ادعاهای سفارشی

@UtilityClass
public class QueryCountAssertions {
    @SneakyThrows
    public static <T> T assertQueryCount(Supplier<T> supplier, Expectation expectation) {
        QueryCountService.clear();
        final var result = supplier.get();
        final var queryCount = QueryCountService.get();
        assertAll(
            () -> {
                if (expectation.selects >= 0) {
                    assertEquals(expectation.selects, queryCount.getSelect(), "Unexpected selects count");
                }
            },
            () -> {
                if (expectation.inserts >= 0) {
                    assertEquals(expectation.inserts, queryCount.getInsert(), "Unexpected inserts count");
                }
            },
            () -> {
                if (expectation.deletes >= 0) {
                    assertEquals(expectation.deletes, queryCount.getDelete(), "Unexpected deletes count");
                }
            },
            () -> {
                if (expectation.updates >= 0) {
                    assertEquals(expectation.updates, queryCount.getUpdate(), "Unexpected updates count");
                }
            }
        );
        return result;
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

الگوریتم ساده است:

  1. تعداد پرس و جوهای فعلی را روی صفر تنظیم کنید.
  2. لامبدای ارائه شده را اجرا کرد.
  3. تعداد پرس و جو را به مقدار داده شده ثابت کنید Expectation هدف – شی.
  4. اگر همه چیز با موفقیت انجام شد، نتیجه اجرا را برگردانید.

همچنین، شما متوجه یک شرط اضافی شده اید. اگر نوع شمارش ارائه شده کمتر از صفر است، از ادعا صرف نظر کنید. این راحت است، زمانی که به سایر پرس و جوها اهمیت نمی دهید.

این Expectation کلاس فقط یک ساختار داده معمولی است. به بیانیه زیر نگاه کنید:

@With
@AllArgsConstructor
@NoArgsConstructor
public static class Expectation {
    private int selects = -1;
    private int inserts = -1;
    private int deletes = -1;
    private int updates = -1;

    public static Expectation ofSelects(int selects) {
        return new Expectation().withSelects(selects);
    }

    public static Expectation ofInserts(int inserts) {
        return new Expectation().withInserts(inserts);
    }

    public static Expectation ofDeletes(int deletes) {
        return new Expectation().withDeletes(deletes);
    }

    public static Expectation ofUpdates(int updates) {
        return new Expectation().withUpdates(updates);
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

نمونه نهایی

بیایید ببینیم چگونه کار می کند. در ابتدا، اظهارات پرس و جو را در مورد قبلی با مشکل N + 1 اضافه می کنم. به بلوک کد زیر نگاه کنید:

final var allZoos = assertQueryCount(
    () -> zooService.findAllZoos(),
    ofSelects(1)
);
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

واردات را فراموش نکنید DatasourceProxyBeanPostProcessor به عنوان یک لوبیا بهاری در تست های شما.

اگر تست را دوباره اجرا کنیم، خروجی زیر را دریافت خواهیم کرد.

Multiple Failures (1 failure)
    org.opentest4j.AssertionFailedError: Unexpected selects count ==> expected: <1> but was: <3>
Expected :1
Actual   :3
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

بنابراین، ادعا کار می کند. ما موفق شدیم مشکل N + 1 را به طور خودکار ردیابی کنیم. زمان جایگزینی انتخاب معمولی با JOIN FETCH. به قطعه کد زیر نگاه کنید.

public interface ZooRepository extends JpaRepository<Zoo, Long> {
    @Query("FROM Zoo z LEFT JOIN FETCH z.animals")
    List<Zoo> findAllWithAnimalsJoined();
}

@Service
@RequiredArgsConstructor
public class ZooService {
    private final ZooRepository zooRepository;

    @Transactional(readOnly = true)
    public List<ZooResponse> findAllZoos() {
        final var zoos = zooRepository.findAllWithAnimalsJoined();
        return zoos.stream()
                   .map(ZooResponse::new)
                   .toList();
    }
}
وارد حالت تمام صفحه شوید

از حالت تمام صفحه خارج شوید

بیایید دوباره تست را اجرا کنیم و نتیجه را بررسی کنیم:

نتیجه آزمایش با JOIN FETCH

به این معنی که ادعا N + 1 مشکلات را به درستی دنبال می کند. علاوه بر این، اگر تعداد پرس و جوها با مقدار مورد انتظار برابر باشد، با موفقیت عبور می کند. عالی!

نتیجه

در حقیقت، می توان با آزمایش های منظم از مشکلات N + 1 جلوگیری کرد. من فکر می‌کنم این یک فرصت عالی برای قرار دادن محافظ برای آن بخش‌های کدی است که برای دیدگاه عملکرد بسیار مهم هستند.

این تمام چیزی است که می خواستم در مورد مقابله با مشکل N + 1 به صورت خودکار به شما بگویم. اگر سوال یا پیشنهادی دارید، نظرات خود را در پایین همین صفحه با ما در میان بگذارید. همچنین اگر این قطعه را دوست داشتید، آن را با دوستان و همکاران خود به اشتراک بگذارید. شاید آنها نیز آن را مفید بدانند. با تشکر برای خواندن!

منابع

  1. مخزن با نمونه کد
  2. مقاله من “تست بوت بهاره – داده ها و خدمات”
  3. ظروف آزمایش
  4. کتابخانه پروکسی منبع داده
  5. مثال رابط BeanPostProcessor
  6. ثبت عبارات SQL

نوشته های مشابه

دیدگاهتان را بنویسید

نشانی ایمیل شما منتشر نخواهد شد. بخش‌های موردنیاز علامت‌گذاری شده‌اند *

دکمه بازگشت به بالا