Spring Batch ile Toplu Veri İşleme
Giriş — Gece Vardiyası
Bir fabrika düşün. Gündüz müşteriler geliyor, siparişler alınıyor, ürünler satılıyor. Ama fabrikanın bir de gece vardiyası var: gündüz biriken siparişleri paketleme, envanter sayımı, raporlama. Bu işler tek tek yapılmaz — toplu yapılır. Binlerce paketi tek tek değil, konveyör bantla paletler halinde işlersin.
İşte batch processing (toplu veri işleme) tam olarak bu gece vardiyasıdır. Gündüz uygulamanız canlı trafik alırken, batch işler trafiğin düştüğü saatlerde devreye girer: binlerce faturayı oluştur, milyonlarca kaydı raporla, CSV dosyalarını veritabanına aktar.
Batch processing'in karakteristik özellikleri:
Büyük hacim: Binlerce, milyonlarca kayıt işlenir
İnsan müdahalesi yok: Otomatik başlar, otomatik biter
Bölünebilir: Hata olursa kaldığı yerden devam edebilir
Zamanlanabilir: Belirli saatlerde, periyodik olarak çalışır
Spring Batch, bu tür işleri yazmak için Java dünyasının standart framework'üdür. Hata yönetimi, restart, chunk processing, parallelism... Tüm bu karmaşıklığı senin yerine yönetir.
Proje Kurulumu
Bu ders Wandbox'ta çalışmaz — local Spring Boot projesi gerekir. [start.spring.io](https://start.spring.io) adresinden Spring Batch, Spring Data JPA, H2 Database, Spring Web dependency'leriyle proje oluştur.
pom.xml:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-batch</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>application.yml:
spring:
batch:
jdbc:
initialize-schema: always # Batch metadata tablolarını otomatik oluştur
job:
enabled: false # Uygulama başlarken otomatik çalışmasın
datasource:
url: jdbc:h2:mem:batchdb
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
spring.batch.job.enabled=falseönemli. Varsayılanda Spring Batch uygulama ayağa kalkarken tüm Job'ları otomatik çalıştırır. Production'da bunu kapatıp job'ları manuel veya schedule ile tetiklersin.
Spring Batch Mimarisi — Konveyör Bant
Spring Batch'in mimarisi fabrika analojisiyle birebir örtüşür:
Job (Gece Vardiyası Planı)
└── Step 1: CSV'den oku → Dönüştür → DB'ye yaz
└── Step 2: Rapor oluştur
└── Step 3: E-posta gönder| Bileşen | Fabrika Karşılığı | Açıklama |
|---|---|---|
| Job | Gece vardiyası planı | Tüm batch işinin tanımı |
| Step | İş istasyonu | Job içindeki her bir adım |
| ItemReader | Ham madde deposu | Veriyi kaynaktan okur |
| ItemProcessor | İşleme hattı | Veriyi dönüştürür, filtreler |
| ItemWriter | Paketleme/sevkiyat | İşlenmiş veriyi hedefe yazar |
| JobLauncher | Vardiya amiri | Job'u başlatır |
| JobRepository | Kayıt defteri | Job'ların durumunu takip eder |
Chunk-Oriented Processing
Spring Batch verileri tek tek değil, chunk (yığın) halinde işler. 10.000 kayıt varsa ve chunk size 100 ise:
Reader 100 kayıt okur → Processor 100 kaydı işler → Writer 100 kaydı yazar → commit
Sonraki 100 kayda geçilir... (100 tekrar)
Bu yaklaşımın avantajları:
Bellek verimli: Tüm veri bir anda RAM'e yüklenmez
Transaction güvenliği: Hata olursa sadece son chunk geri alınır
Restart: Tamamlanan chunk'lar tekrar çalıştırılmaz
ItemReader — Veri Kaynakları
Reader, batch job'un girdi noktası. Önce bir entity tanımlayalım:
@Entity
@Table(name = "employees")
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String firstName;
private String lastName;
private String email;
private String department;
private Double salary;
public Employee() {}
public Employee(String firstName, String lastName, String email,
String department, Double salary) {
this.firstName = firstName;
this.lastName = lastName;
this.email = email;
this.department = department;
this.salary = salary;
}
// Getter/Setter'lar
}FlatFileItemReader — CSV Dosyaları
@Bean
public FlatFileItemReader<Employee> csvReader() {
return new FlatFileItemReaderBuilder<Employee>()
.name("employeeCsvReader")
.resource(new ClassPathResource("employees.csv"))
.linesToSkip(1) // İlk satır header, atla
.delimited() // Virgülle ayrılmış
.names("firstName", "lastName", "email", "department", "salary")
.targetType(Employee.class)
.build();
}src/main/resources/employees.csv:
firstName,lastName,email,department,salary
Ahmet,Yılmaz,ahmet@example.com,Engineering,15000
Ayşe,Kaya,ayse@example.com,Marketing,12000
Mehmet,Demir,mehmet@example.com,Engineering,16000JdbcCursorItemReader — Veritabanından Okuma
@Bean
public JdbcCursorItemReader<Employee> jdbcReader(DataSource dataSource) {
return new JdbcCursorItemReaderBuilder<Employee>()
.name("employeeJdbcReader")
.dataSource(dataSource)
.sql("SELECT first_name, last_name, email, department, salary " +
"FROM employees WHERE processed = false")
.rowMapper((rs, rowNum) -> new Employee(
rs.getString("first_name"),
rs.getString("last_name"),
rs.getString("email"),
rs.getString("department"),
rs.getDouble("salary")
))
.build();
}Veritabanı cursor'ı açar ve satır satır okur. Tüm sonucu RAM'e çekmez — büyük veri setlerinde bellek dostu.
JpaPagingItemReader — JPA ile Sayfalı Okuma
@Bean
public JpaPagingItemReader<Employee> jpaReader(
EntityManagerFactory entityManagerFactory) {
return new JpaPagingItemReaderBuilder<Employee>()
.name("employeeJpaReader")
.entityManagerFactory(entityManagerFactory)
.queryString("SELECT e FROM Employee e WHERE e.department = :dept")
.parameterValues(Map.of("dept", "Engineering"))
.pageSize(50)
.build();
}Her seferinde pageSize kadar kayıt getirir. Açık cursor tutmaz — her sayfa ayrı sorgu. Milyonlarca kayıt olan tablolarda daha güvenli.
💡 Hangi Reader Ne Zaman? Dosyadan → FlatFileItemReader. Küçük-orta tablo, hızlı → JdbcCursorItemReader. Büyük tablo, JPA entity var → JpaPagingItemReader. API'den okuma → Custom ItemReader yaz.
ItemProcessor — Dönüştür, Filtrele, Doğrula
Processor, Reader'dan gelen veriyi hedefe yazılmadan önce dönüştürür. null döndürürsen o kayıt filtrelenir — Writer'a ulaşmaz.
Dönüştürme ve Filtreleme
@Bean
public ItemProcessor<Employee, Employee> employeeProcessor() {
return employee -> {
// Email formatı kontrolü — geçersizse filtrele
if (employee.getEmail() == null
|| !employee.getEmail().contains("@")) {
return null; // Bu kayıt atlanır
}
// %10 zam uygula
employee.setSalary(employee.getSalary() * 1.10);
// İsmi normalize et
employee.setFirstName(employee.getFirstName().toUpperCase());
employee.setLastName(employee.getLastName().toUpperCase());
return employee;
};
}CompositeItemProcessor — Zincirleme
Birden fazla processor'ı sırayla çalıştırmak istersen:
@Bean
public CompositeItemProcessor<Employee, Employee> compositeProcessor() {
CompositeItemProcessor<Employee, Employee> composite =
new CompositeItemProcessor<>();
composite.setDelegates(List.of(
validationProcessor(), // Önce doğrula
filterProcessor(), // Sonra filtrele
salaryProcessor() // En son dönüştür
));
return composite;
}ItemWriter — Hedefe Yazma
Writer, chunk halinde çalışır — write() metodu listeyle çağrılır.
JpaItemWriter
@Bean
public JpaItemWriter<Employee> jpaWriter(
EntityManagerFactory entityManagerFactory) {
JpaItemWriter<Employee> writer = new JpaItemWriter<>();
writer.setEntityManagerFactory(entityManagerFactory);
return writer;
}Entity'yi veritabanına persist eder. @Id stratejisine göre INSERT veya UPDATE yapar.
JdbcBatchItemWriter
JPA overhead'i istemiyorsan, ham JDBC ile:
@Bean
public JdbcBatchItemWriter<Employee> jdbcWriter(DataSource dataSource) {
return new JdbcBatchItemWriterBuilder<Employee>()
.dataSource(dataSource)
.sql("INSERT INTO employees (first_name, last_name, email, " +
"department, salary) VALUES (:firstName, :lastName, " +
":email, :department, :salary)")
.beanMapped()
.build();
}JDBC batch API'sini kullanır — JPA'ya göre daha hızlıdır çünkü entity lifecycle yönetimi yoktur.
FlatFileItemWriter — Dosyaya Yazma
@Bean
public FlatFileItemWriter<Employee> fileWriter() {
return new FlatFileItemWriterBuilder<Employee>()
.name("employeeFileWriter")
.resource(new FileSystemResource("output/processed-employees.csv"))
.headerCallback(writer -> writer.write(
"firstName,lastName,email,department,salary"))
.delimited()
.delimiter(",")
.names("firstName", "lastName", "email", "department", "salary")
.build();
}Job ve Step Tanımlama
Reader, Processor ve Writer'ı bir Step içinde birleştirip, Step'leri bir Job altında topluyoruz.
@Configuration
public class EmployeeBatchConfig {
@Bean
public Job employeeImportJob(JobRepository jobRepository,
Step csvToDbStep,
Step summaryStep) {
return new JobBuilder("employeeImportJob", jobRepository)
.start(csvToDbStep)
.next(summaryStep)
.build();
}
@Bean
public Step csvToDbStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager,
FlatFileItemReader<Employee> reader,
ItemProcessor<Employee, Employee> processor,
JpaItemWriter<Employee> writer) {
return new StepBuilder("csvToDbStep", jobRepository)
.<Employee, Employee>chunk(100, transactionManager)
.reader(reader)
.processor(processor)
.writer(writer)
.build();
}
@Bean
public Step summaryStep(JobRepository jobRepository,
PlatformTransactionManager transactionManager) {
return new StepBuilder("summaryStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println("=== Import Tamamlandı ===");
return RepeatStatus.FINISHED;
}, transactionManager)
.build();
}
}İki tip Step var:
Chunk-oriented Step: Reader → Processor → Writer döngüsü. Büyük veri setleri için.
Tasklet Step: Tek seferlik iş. Dosya silme, rapor oluşturma, bildirim gönderme.
Chunk Size Optimizasyonu
| Chunk Size | Avantaj | Dezavantaj |
|---|---|---|
| Küçük (10-50) | Az bellek, sık commit | Çok transaction, yavaş |
| Orta (100-500) | Dengeli performans | Genel amaçlı iyi seçim |
| Büyük (1000+) | Az transaction, hızlı | Çok bellek, büyük rollback |
100-500 arasında başla, profiling yaparak optimize et.
Job Parametreleri ve JobLauncher
Her Job çalıştırmasına parametre geçebilirsin. Parametreler Job instance'ını benzersiz kılar — aynı parametrelerle aynı Job tekrar çalıştırılamaz (restart hariç).
@RestController
@RequestMapping("/api/batch")
public class BatchController {
private final JobLauncher jobLauncher;
private final Job employeeImportJob;
public BatchController(JobLauncher jobLauncher,
@Qualifier("employeeImportJob") Job job) {
this.jobLauncher = jobLauncher;
this.employeeImportJob = job;
}
@PostMapping("/run")
public ResponseEntity<String> runJob(
@RequestParam(defaultValue = "employees.csv") String fileName) {
try {
JobParameters params = new JobParametersBuilder()
.addString("inputFile", fileName)
.addLocalDateTime("runTime", LocalDateTime.now())
.toJobParameters();
JobExecution execution = jobLauncher.run(
employeeImportJob, params);
return ResponseEntity.ok(
"Job başlatıldı. Status: " + execution.getStatus());
} catch (Exception e) {
return ResponseEntity.status(500)
.body("Job başlatılamadı: " + e.getMessage());
}
}
}Job Parametrelerine Step İçinden Erişim
@Bean
@StepScope // Her Step çalışmasında yeni instance
public FlatFileItemReader<Employee> csvReader(
@Value("#{jobParameters['inputFile']}") String inputFile) {
return new FlatFileItemReaderBuilder<Employee>()
.name("employeeCsvReader")
.resource(new ClassPathResource(inputFile))
.linesToSkip(1)
.delimited()
.names("firstName", "lastName", "email", "department", "salary")
.targetType(Employee.class)
.build();
}@StepScope kritik. Bu olmadan #{jobParameters[...]} çalışmaz — bean uygulama başlangıcında oluşturulur, o anda henüz job parametresi yoktur. @StepScope ile bean, Step çalıştığında lazy oluşturulur.
Hata Yönetimi — Skip, Retry, Restart
Batch işlerde milyonlarca kayıt işlenirken bir satır bozuk CSV olabilir, veritabanı bir an erişilemez olabilir. Spring Batch üç temel hata stratejisi sunar.
Skip — Hatalı Kaydı Atla
@Bean
public Step faultTolerantStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
FlatFileItemReader<Employee> reader,
ItemProcessor<Employee, Employee> processor,
JpaItemWriter<Employee> writer) {
return new StepBuilder("faultTolerantStep", jobRepository)
.<Employee, Employee>chunk(100, txManager)
.reader(reader)
.processor(processor)
.writer(writer)
.faultTolerant()
.skip(FlatFileParseException.class) // Parse hatalarını atla
.skip(ValidationException.class) // Validation hatalarını atla
.skipLimit(50) // En fazla 50 kayıt atlanabilir
.listener(new SkipListener<Employee, Employee>() {
@Override
public void onSkipInRead(Throwable t) {
System.err.println("Okuma hatası, atlandı: "
+ t.getMessage());
}
@Override
public void onSkipInProcess(Employee item, Throwable t) {
System.err.println("İşleme hatası: " + item.getEmail());
}
@Override
public void onSkipInWrite(Employee item, Throwable t) {
System.err.println("Yazma hatası: " + item.getEmail());
}
})
.build();
}skipLimit(50) demek "50 hataya kadar tolere et, 51. hatada job'u durdur." 10 milyon kayıtta 50 hata kabul edilebilir, 100 kayıtta 50 hata ciddi sorun.
Retry — Geçici Hatalarda Tekrar Dene
Veritabanı bağlantı hatası, network timeout gibi geçici hatalar için:
@Bean
public Step resilientStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
ItemReader<Employee> reader,
ItemProcessor<Employee, Employee> processor,
ItemWriter<Employee> writer) {
return new StepBuilder("resilientStep", jobRepository)
.<Employee, Employee>chunk(100, txManager)
.reader(reader)
.processor(processor)
.writer(writer)
.faultTolerant()
.retry(DeadlockLoserDataAccessException.class)
.retry(ConnectTimeoutException.class)
.retryLimit(3) // Max 3 deneme
.skip(ValidationException.class) // Validation'ı atla
.skipLimit(100)
.build();
}Retry ve skip birlikte kullanılabilir. Retry başarısız olursa, o hata skip kurallarına düşer.
Restart — Kaldığı Yerden Devam
Spring Batch her chunk'ın commit noktasını JobRepository'ye kaydeder. Job dursa bile, aynı parametrelerle tekrar çalıştırıldığında kaldığı yerden devam eder. Bu restart yeteneği için JobRepository'nin kalıcı olması gerekir — H2 in-memory yerine gerçek bir veritabanı kullan.
⚠️ Production'da `initialize-schema: always` kullanma. Batch metadata tablolarını Flyway veya Liquibase migration'larıyla oluştur ve yönet.
Bütünleşik Proje: CSV'den Veritabanına Aktarım
Tüm parçaları bir araya getirelim. Senaryo: employees.csv dosyasını oku, doğrula, dönüştür, veritabanına yaz.
@Configuration
public class EmployeeBatchConfig {
@Bean
@StepScope
public FlatFileItemReader<Employee> csvReader(
@Value("#{jobParameters['inputFile'] ?: 'employees.csv'}")
String inputFile) {
return new FlatFileItemReaderBuilder<Employee>()
.name("employeeCsvReader")
.resource(new ClassPathResource(inputFile))
.linesToSkip(1)
.delimited()
.names("firstName", "lastName", "email",
"department", "salary")
.targetType(Employee.class)
.build();
}
@Bean
public ItemProcessor<Employee, Employee> employeeProcessor() {
return employee -> {
if (employee.getEmail() == null
|| !employee.getEmail().contains("@")) {
return null; // Geçersiz email → filtrele
}
employee.setDepartment(
employee.getDepartment().trim().toUpperCase());
employee.setFirstName(capitalize(employee.getFirstName()));
employee.setLastName(capitalize(employee.getLastName()));
return employee;
};
}
@Bean
public JpaItemWriter<Employee> employeeWriter(
EntityManagerFactory emf) {
JpaItemWriter<Employee> writer = new JpaItemWriter<>();
writer.setEntityManagerFactory(emf);
return writer;
}
@Bean
public Step importStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
FlatFileItemReader<Employee> csvReader,
ItemProcessor<Employee, Employee> processor,
JpaItemWriter<Employee> writer) {
return new StepBuilder("importStep", jobRepository)
.<Employee, Employee>chunk(100, txManager)
.reader(csvReader)
.processor(processor)
.writer(writer)
.faultTolerant()
.skip(FlatFileParseException.class)
.skipLimit(10)
.build();
}
@Bean
public Step summaryStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
EmployeeRepository repo) {
return new StepBuilder("summaryStep", jobRepository)
.tasklet((contribution, chunkContext) -> {
System.out.println("=== Import Tamamlandı ===");
System.out.println("Toplam kayıt: " + repo.count());
return RepeatStatus.FINISHED;
}, txManager)
.build();
}
@Bean
public Job employeeImportJob(JobRepository jobRepository,
Step importStep,
Step summaryStep) {
return new JobBuilder("employeeImportJob", jobRepository)
.start(importStep)
.next(summaryStep)
.build();
}
private String capitalize(String str) {
if (str == null || str.isEmpty()) return str;
return str.substring(0, 1).toUpperCase()
+ str.substring(1).toLowerCase();
}
}Test:
mvn spring-boot:run
curl -X POST "http://localhost:8080/api/batch/run?fileName=employees.csv"Scheduling ile Batch Job Tetikleme
Gerçek dünyada batch job'lar zamanlama ile çalışır. Spring'in @Scheduled annotation'ı ile:
@Component
public class BatchScheduler {
private final JobLauncher jobLauncher;
private final Job employeeImportJob;
public BatchScheduler(JobLauncher jobLauncher,
@Qualifier("employeeImportJob") Job job) {
this.jobLauncher = jobLauncher;
this.employeeImportJob = job;
}
@Scheduled(cron = "0 0 2 * * *") // Her gece 02:00
public void runNightlyImport() {
try {
JobParameters params = new JobParametersBuilder()
.addLocalDateTime("scheduledAt", LocalDateTime.now())
.toJobParameters();
JobExecution execution = jobLauncher.run(
employeeImportJob, params);
System.out.println("Nightly import: " + execution.getStatus());
} catch (Exception e) {
System.err.println("Batch job başarısız: " + e.getMessage());
}
}
}Main class'ta @EnableScheduling olmalı:
@SpringBootApplication
@EnableScheduling
public class BatchDemoApplication {
public static void main(String[] args) {
SpringApplication.run(BatchDemoApplication.class, args);
}
}Cron İfade Formatı (Spring)
saniye dakika saat gün ay haftanın_günü
"0 0 2 * * *" → Her gün 02:00
"0 0 2 * * MON-FRI" → Hafta içi 02:00
"0 0 */6 * * *" → Her 6 saatte bir
"0 30 1 1 * *" → Her ayın 1'i 01:30İleri Konular
Koşullu Step Akışı
@Bean
public Job conditionalJob(JobRepository jobRepository,
Step validateStep,
Step importStep,
Step errorStep) {
return new JobBuilder("conditionalJob", jobRepository)
.start(validateStep)
.on("FAILED").to(errorStep)
.from(validateStep)
.on("*").to(importStep)
.end()
.build();
}Job Execution Listener
@Component
public class JobCompletionListener implements JobExecutionListener {
@Override
public void beforeJob(JobExecution execution) {
System.out.println("Job başlıyor: "
+ execution.getJobInstance().getJobName());
}
@Override
public void afterJob(JobExecution execution) {
if (execution.getStatus() == BatchStatus.COMPLETED) {
System.out.println("Job BAŞARILI!");
} else if (execution.getStatus() == BatchStatus.FAILED) {
System.err.println("Job BAŞARISIZ: "
+ execution.getAllFailureExceptions());
}
execution.getStepExecutions().forEach(step ->
System.out.printf("Step '%s': Read=%d, Written=%d, " +
"Skipped=%d%n",
step.getStepName(), step.getReadCount(),
step.getWriteCount(), step.getSkipCount())
);
}
}Multi-threaded Step
@Bean
public Step parallelStep(JobRepository jobRepository,
PlatformTransactionManager txManager,
FlatFileItemReader<Employee> reader,
ItemProcessor<Employee, Employee> processor,
JpaItemWriter<Employee> writer) {
return new StepBuilder("parallelStep", jobRepository)
.<Employee, Employee>chunk(100, txManager)
.reader(reader)
.processor(processor)
.writer(writer)
.taskExecutor(new SimpleAsyncTaskExecutor())
.throttleLimit(4) // Max 4 thread
.build();
}⚠️ Multi-threaded step'te Reader thread-safe olmalı. FlatFileItemReader varsayılanda thread-safe değildir — SynchronizedItemStreamReader ile sarmalaman gerekir. JpaPagingItemReader ise thread-safe'dir.
Yaygın Hatalar
1. Job Parametreleri Benzersiz Olmalı
// ❌ Her seferinde aynı parametreler → Job tekrar çalışmaz
JobParameters params = new JobParametersBuilder()
.addString("inputFile", "employees.csv")
.toJobParameters();
// ✅ Her çalıştırmada benzersiz parametre ekle
JobParameters params = new JobParametersBuilder()
.addString("inputFile", "employees.csv")
.addLocalDateTime("runTime", LocalDateTime.now())
.toJobParameters();2. @StepScope Unutma
// ❌ jobParameters çalışmaz — bean başlangıçta oluşturulur
@Bean
public FlatFileItemReader<Employee> reader(
@Value("#{jobParameters['file']}") String file) { }
// ✅ @StepScope ile lazy oluştur
@Bean
@StepScope
public FlatFileItemReader<Employee> reader(
@Value("#{jobParameters['file']}") String file) { }3. Transaction Sınırlarını Anla
Chunk size 100 ve 99. kayıtta hata olursa, 100 kaydın hepsi geri alınır. Çok büyük chunk size riskli olabilir — dengeyi bul.
Özet
Spring Batch, büyük hacimli veri işleme için standart Java framework'üdür — Job → Step → (Reader → Processor → Writer) mimarisiyle çalışır
Chunk-oriented processing, veriyi parça parça işler — bellek verimli, transaction güvenli, restart destekli
ItemReader (CSV, JDBC, JPA), ItemProcessor (dönüştürme, filtreleme) ve ItemWriter (DB, dosya) hazır bileşenleriyle çoğu senaryo karşılanır
Skip ve retry mekanizmaları hatalı kayıtları yönetir — milyonlarca kayıtta birkaç hata tüm job'u durdurmaz
Job parametreleri her çalıştırmayı benzersiz kılar, @StepScope ile runtime'da parametre injection yapılır
@Scheduled + JobLauncher kombinasyonuyla batch job'lar periyodik olarak otomatik tetiklenir
AI Asistan
Sorularını yanıtlamaya hazır