تست های یکپارچه سازی برای مشکل N + 1 در جاوا
مشکل N + 1 یک مسئله رایج در بسیاری از پروژه های سازمانی است. بدترین چیز این است که تا زمانی که حجم داده ها زیاد نشود متوجه آن نمی شوید. متأسفانه، ممکن است کد به مرحله ای برسد که مقابله با مشکل N + 1 تبدیل به یک کار غیرقابل تحمل شود.
در این مقاله به شما می گویم:
- چگونه به طور خودکار مشکل N + 1 را ردیابی کنیم؟
- چگونه یک آزمایش بنویسیم تا بررسی کنیم که تعداد پرس و جو از مقدار مورد انتظار تجاوز نمی کند؟
پشته فناوری شامل جاوا، 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;
}
}
الگوریتم ساده است:
- تعداد پرس و جوهای فعلی را روی صفر تنظیم کنید.
- لامبدای ارائه شده را اجرا کرد.
- تعداد پرس و جو را به مقدار داده شده ثابت کنید
Expectation
هدف – شی. - اگر همه چیز با موفقیت انجام شد، نتیجه اجرا را برگردانید.
همچنین، شما متوجه یک شرط اضافی شده اید. اگر نوع شمارش ارائه شده کمتر از صفر است، از ادعا صرف نظر کنید. این راحت است، زمانی که به سایر پرس و جوها اهمیت نمی دهید.
این 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();
}
}
بیایید دوباره تست را اجرا کنیم و نتیجه را بررسی کنیم:
به این معنی که ادعا N + 1 مشکلات را به درستی دنبال می کند. علاوه بر این، اگر تعداد پرس و جوها با مقدار مورد انتظار برابر باشد، با موفقیت عبور می کند. عالی!
نتیجه
در حقیقت، می توان با آزمایش های منظم از مشکلات N + 1 جلوگیری کرد. من فکر میکنم این یک فرصت عالی برای قرار دادن محافظ برای آن بخشهای کدی است که برای دیدگاه عملکرد بسیار مهم هستند.
این تمام چیزی است که می خواستم در مورد مقابله با مشکل N + 1 به صورت خودکار به شما بگویم. اگر سوال یا پیشنهادی دارید، نظرات خود را در پایین همین صفحه با ما در میان بگذارید. همچنین اگر این قطعه را دوست داشتید، آن را با دوستان و همکاران خود به اشتراک بگذارید. شاید آنها نیز آن را مفید بدانند. با تشکر برای خواندن!
منابع
- مخزن با نمونه کد
- مقاله من “تست بوت بهاره – داده ها و خدمات”
- ظروف آزمایش
- کتابخانه پروکسی منبع داده
- مثال رابط BeanPostProcessor
- ثبت عبارات SQL