2023-09 spring-27-spring-batch added

This commit is contained in:
stvort
2024-01-23 10:20:32 +04:00
parent d51bfc0828
commit d8f50f7eef
18 changed files with 790 additions and 0 deletions
@@ -0,0 +1,9 @@
.idea/
*.iml
target/
output.csv
*.log
output*.dat
test-output.dat
@@ -0,0 +1,16 @@
Ivan,23
John,24
Ivan,23
John,24
Ivan,23
Mary,24
Ivan,23
John,24
Sunny,23
John,24
Ivan,23
John,24
Ivan,23
John,24
Ivan,23
John,24
1 Ivan 23
2 John 24
3 Ivan 23
4 John 24
5 Ivan 23
6 Mary 24
7 Ivan 23
8 John 24
9 Sunny 23
10 John 24
11 Ivan 23
12 John 24
13 Ivan 23
14 John 24
15 Ivan 23
16 John 24
+136
View File
@@ -0,0 +1,136 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ru.otus.example</groupId>
<artifactId>spring-batch-demo</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<!--<version>3.1.5</version>-->
<version>3.2.1</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
<maven.compiler.sourcre>17</maven.compiler.sourcre>
<maven.compiler.target>17</maven.compiler.target>
<mongock.version>4.3.8</mongock.version>
<flapdoodle.version>4.6.1</flapdoodle.version>
<h2.version>2.2.220</h2.version>
<snakeyaml.version>2.0</snakeyaml.version>
<guava.version>32.1.2-jre</guava.version>
</properties>
<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>org.yaml</groupId>
<artifactId>snakeyaml</artifactId>
<version>${snakeyaml.version}</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
<version>${h2.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-mongodb</artifactId>
</dependency>
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo</artifactId>
<version>${flapdoodle.version}</version>
</dependency>
<dependency>
<groupId>de.flapdoodle.embed</groupId>
<artifactId>de.flapdoodle.embed.mongo.spring30x</artifactId>
<version>${flapdoodle.version}</version>
</dependency>
<dependency>
<groupId>com.github.cloudyrock.mongock</groupId>
<artifactId>mongock-spring-v5</artifactId>
<version>${mongock.version}</version>
<exclusions>
<exclusion>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>com.github.cloudyrock.mongock</groupId>
<artifactId>mongodb-springdata-v3-driver</artifactId>
<version>${mongock.version}</version>
</dependency>
<!--
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.shell</groupId>
<artifactId>spring-shell-starter</artifactId>
<version>3.0.3</version>
</dependency>
<!--Тестирование-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.batch</groupId>
<artifactId>spring-batch-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>spring-batch-demo</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
@@ -0,0 +1,16 @@
package ru.otus.example.springbatch;
import com.github.cloudyrock.spring.v5.EnableMongock;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@EnableMongock
@SpringBootApplication
public class Main {
// --spring.shell.interactive.enabled=false --spring.batch.job.enabled=true inputFileName=entries.csv outputFileName=output_new.dat
public static void main(String[] args) {
SpringApplication.run(Main.class, args);
}
}
@@ -0,0 +1,45 @@
package ru.otus.example.springbatch.chandgelogs;
import com.github.cloudyrock.mongock.ChangeLog;
import com.github.cloudyrock.mongock.ChangeSet;
import com.github.cloudyrock.mongock.driver.mongodb.springdata.v3.decorator.impl.MongockTemplate;
import com.mongodb.client.MongoDatabase;
import ru.otus.example.springbatch.model.Person;
@ChangeLog(order = "001")
public class InitMongoDBDataChangeLog {
@ChangeSet(order = "000", id = "dropDB", author = "stvort", runAlways = true)
public void dropDB(MongoDatabase database){
database.drop();
}
@ChangeSet(order = "001", id = "initPersons", author = "stvort", runAlways = true)
public void initPersons(MongockTemplate template){
template.save(new Person("Джон", 21));
template.save(new Person("Игорь", 32));
template.save(new Person("Дмитрий", 52));
template.save(new Person("Михаил", 22));
template.save(new Person("Герман", 33));
template.save(new Person("Джон", 21));
template.save(new Person("Игорь", 32));
template.save(new Person("Дмитрий", 52));
template.save(new Person("Михаил", 22));
template.save(new Person("Герман", 33));
template.save(new Person("Джон", 21));
template.save(new Person("Игорь", 32));
template.save(new Person("Дмитрий", 52));
template.save(new Person("Михаил", 22));
template.save(new Person("Герман", 33));
template.save(new Person("Джон", 21));
template.save(new Person("Игорь", 32));
template.save(new Person("Дмитрий", 52));
template.save(new Person("Михаил", 22));
template.save(new Person("Герман", 33));
template.save(new Person("Джон", 21));
template.save(new Person("Игорь", 32));
template.save(new Person("Дмитрий", 52));
template.save(new Person("Михаил", 22));
template.save(new Person("Герман", 33));
}
}
@@ -0,0 +1,14 @@
package ru.otus.example.springbatch.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties("app")
public class AppProps {
private String inputFile;
private String outputFile;
}
@@ -0,0 +1,17 @@
package ru.otus.example.springbatch.config;
import org.springframework.batch.core.configuration.JobRegistry;
import org.springframework.batch.core.configuration.support.JobRegistryBeanPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@SuppressWarnings("unused")
//@Configuration
public class BatchConfig {
@Bean
public JobRegistryBeanPostProcessor postProcessor(JobRegistry jobRegistry) {
var processor = new JobRegistryBeanPostProcessor();
processor.setJobRegistry(jobRegistry);
return processor;
}
}
@@ -0,0 +1,188 @@
package ru.otus.example.springbatch.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.*;
import org.springframework.batch.core.configuration.annotation.StepScope;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.core.step.tasklet.MethodInvokingTaskletAdapter;
import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.file.FlatFileItemReader;
import org.springframework.batch.item.file.FlatFileItemWriter;
import org.springframework.batch.item.file.builder.FlatFileItemReaderBuilder;
import org.springframework.batch.item.file.builder.FlatFileItemWriterBuilder;
import org.springframework.batch.item.file.mapping.BeanWrapperFieldSetMapper;
import org.springframework.batch.item.file.transform.DelimitedLineAggregator;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.FileSystemResource;
import org.springframework.lang.NonNull;
import org.springframework.transaction.PlatformTransactionManager;
import ru.otus.example.springbatch.model.Person;
import ru.otus.example.springbatch.service.CleanUpService;
import ru.otus.example.springbatch.service.HappyBirthdayService;
import java.util.List;
@SuppressWarnings("unused")
@Configuration
public class JobConfig {
private static final int CHUNK_SIZE = 5;
private final Logger logger = LoggerFactory.getLogger("Batch");
public static final String OUTPUT_FILE_NAME = "outputFileName";
public static final String INPUT_FILE_NAME = "inputFileName";
public static final String IMPORT_USER_JOB_NAME = "importUserJob";
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager platformTransactionManager;
@Autowired
private CleanUpService cleanUpService;
@StepScope
@Bean
public FlatFileItemReader<Person> reader(@Value("#{jobParameters['" + INPUT_FILE_NAME + "']}") String inputFileName) {
return new FlatFileItemReaderBuilder<Person>()
.name("personItemReader")
.resource(new FileSystemResource(inputFileName))
.delimited()
.names("name", "age")
.fieldSetMapper(new BeanWrapperFieldSetMapper<>() {{
setTargetType(Person.class);
}}).build();
}
@StepScope
@Bean
public ItemProcessor<Person, Person> processor(HappyBirthdayService happyBirthdayService) {
return happyBirthdayService::doHappyBirthday;
}
@StepScope
@Bean
public FlatFileItemWriter<Person> writer(@Value("#{jobParameters['" + OUTPUT_FILE_NAME + "']}") String outputFileName) {
return new FlatFileItemWriterBuilder<Person>()
.name("personItemWriter")
.resource(new FileSystemResource(outputFileName))
.lineAggregator(new DelimitedLineAggregator<>())
.build();
}
@Bean
public MethodInvokingTaskletAdapter cleanUpTasklet() {
MethodInvokingTaskletAdapter adapter = new MethodInvokingTaskletAdapter();
adapter.setTargetObject(cleanUpService);
adapter.setTargetMethod("cleanUp");
return adapter;
}
@Bean
public Job importUserJob(Step transformPersonsStep, Step cleanUpStep) {
return new JobBuilder(IMPORT_USER_JOB_NAME, jobRepository)
.incrementer(new RunIdIncrementer())
.flow(transformPersonsStep)
.next(cleanUpStep)
.end()
.listener(new JobExecutionListener() {
@Override
public void beforeJob(@NonNull JobExecution jobExecution) {
logger.info("Начало job");
}
@Override
public void afterJob(@NonNull JobExecution jobExecution) {
logger.info("Конец job");
}
})
.build();
}
@Bean
public Step transformPersonsStep(ItemReader<Person> reader, FlatFileItemWriter<Person> writer,
ItemProcessor<Person, Person> itemProcessor) {
return new StepBuilder("transformPersonsStep", jobRepository)
.<Person, Person>chunk(CHUNK_SIZE, platformTransactionManager)
.reader(reader)
.processor(itemProcessor)
.writer(writer)
.listener(new ItemReadListener<>() {
public void beforeRead() {
logger.info("Начало чтения");
}
public void afterRead(@NonNull Person o) {
logger.info("Конец чтения");
}
public void onReadError(@NonNull Exception e) {
logger.info("Ошибка чтения");
}
})
.listener(new ItemWriteListener<Person>() {
public void beforeWrite(@NonNull List<Person> list) {
logger.info("Начало записи");
}
public void afterWrite(@NonNull List<Person> list) {
logger.info("Конец записи");
}
public void onWriteError(@NonNull Exception e, @NonNull List<Person> list) {
logger.info("Ошибка записи");
}
})
.listener(new ItemProcessListener<>() {
public void beforeProcess(@NonNull Person o) {
logger.info("Начало обработки");
}
public void afterProcess(@NonNull Person o, Person o2) {
logger.info("Конец обработки");
}
public void onProcessError(@NonNull Person o, @NonNull Exception e) {
logger.info("Ошибка обработки");
}
})
.listener(new ChunkListener() {
public void beforeChunk(@NonNull ChunkContext chunkContext) {
logger.info("Начало пачки");
}
public void afterChunk(@NonNull ChunkContext chunkContext) {
logger.info("Конец пачки");
}
public void afterChunkError(@NonNull ChunkContext chunkContext) {
logger.info("Ошибка пачки");
}
})
// .taskExecutor(new SimpleAsyncTaskExecutor())
.build();
}
@Bean
public Step cleanUpStep() {
return new StepBuilder("cleanUpStep", jobRepository)
.tasklet(cleanUpTasklet(), platformTransactionManager)
.build();
}
}
@@ -0,0 +1,13 @@
package ru.otus.example.springbatch.model;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Person {
private String name;
private int age;
}
@@ -0,0 +1,16 @@
package ru.otus.example.springbatch.service;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@Slf4j
@Service
public class CleanUpService {
@SuppressWarnings("unused")
public void cleanUp() throws Exception {
log.info("Выполняю завершающие мероприятия...");
Thread.sleep(1000);
log.info("Завершающие мероприятия закончены");
}
}
@@ -0,0 +1,13 @@
package ru.otus.example.springbatch.service;
import org.springframework.stereotype.Service;
import ru.otus.example.springbatch.model.Person;
@Service
public class HappyBirthdayService {
public Person doHappyBirthday(Person person){
person.setAge(person.getAge() + 1);
return person;
}
}
@@ -0,0 +1,61 @@
package ru.otus.example.springbatch.shell;
import lombok.RequiredArgsConstructor;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.explore.JobExplorer;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.batch.core.launch.JobOperator;
import org.springframework.shell.standard.ShellComponent;
import org.springframework.shell.standard.ShellMethod;
import ru.otus.example.springbatch.config.AppProps;
import java.util.Properties;
import static ru.otus.example.springbatch.config.JobConfig.IMPORT_USER_JOB_NAME;
import static ru.otus.example.springbatch.config.JobConfig.INPUT_FILE_NAME;
import static ru.otus.example.springbatch.config.JobConfig.OUTPUT_FILE_NAME;
@RequiredArgsConstructor
@ShellComponent
public class BatchCommands {
private final AppProps appProps;
private final Job importUserJob;
private final JobLauncher jobLauncher;
private final JobOperator jobOperator;
private final JobExplorer jobExplorer;
//http://localhost:8080/h2-console/
@SuppressWarnings("unused")
@ShellMethod(value = "startMigrationJobWithJobLauncher", key = "sm-jl")
public void startMigrationJobWithJobLauncher() throws Exception {
JobExecution execution = jobLauncher.run(importUserJob, new JobParametersBuilder()
.addString(INPUT_FILE_NAME, appProps.getInputFile())
.addString(OUTPUT_FILE_NAME, appProps.getOutputFile())
.toJobParameters());
System.out.println(execution);
}
@SuppressWarnings("unused")
@ShellMethod(value = "startMigrationJobWithJobOperator", key = "sm-jo")
public void startMigrationJobWithJobOperator() throws Exception {
Properties properties = new Properties();
properties.put(INPUT_FILE_NAME, appProps.getInputFile());
properties.put(OUTPUT_FILE_NAME, appProps.getOutputFile());
Long executionId = jobOperator.start(IMPORT_USER_JOB_NAME, properties);
System.out.println(jobOperator.getSummary(executionId));
}
@SuppressWarnings("unused")
@ShellMethod(value = "showInfo", key = "i")
public void showInfo() {
System.out.println(jobExplorer.getJobNames());
System.out.println(jobExplorer.getLastJobInstance(IMPORT_USER_JOB_NAME));
}
}
@@ -0,0 +1,55 @@
spring:
main:
allow-circular-references: true
batch:
job:
enabled: false
shell:
interactive:
enabled: true
noninteractive:
enabled: false
command:
version:
enabled: false
data:
mongodb:
host: localhost
port: 0
database: SpringBatchExampleDB
datasource:
url: jdbc:h2:mem:testdb
driverClassName: org.h2.Driver
username: sa
password:
h2:
console:
enabled: true
path: /h2-console
de:
flapdoodle:
mongodb:
embedded:
version: 4.0.2
mongock:
runner-type: "InitializingBean"
change-logs-scan-package:
- ru.otus.example.springbatch.chandgelogs
mongo-db:
write-concern:
journal: false
read-concern: local
app:
ages-count-to-add: 1
input-file: entries.csv
output-file: output.dat
#debug: true
@@ -0,0 +1,71 @@
package ru.otus.example.springbatch.config;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.test.JobLauncherTestUtils;
import org.springframework.batch.test.JobRepositoryTestUtils;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.io.File;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import static org.assertj.core.api.Assertions.assertThat;
import static ru.otus.example.springbatch.config.JobConfig.IMPORT_USER_JOB_NAME;
import static ru.otus.example.springbatch.config.JobConfig.INPUT_FILE_NAME;
import static ru.otus.example.springbatch.config.JobConfig.OUTPUT_FILE_NAME;
@SpringBootTest
@SpringBatchTest
class ImportUserJobTest {
private static final String TEST_INPUT_FILE_NAME = "test-entries.csv";
private static final String EXPECTED_OUTPUT_FILE_NAME = "expected-test-output.dat";
private static final String TEST_OUTPUT_FILE_NAME = "test-output.dat";
@Autowired
private JobLauncherTestUtils jobLauncherTestUtils;
@Autowired
private JobRepositoryTestUtils jobRepositoryTestUtils;
@BeforeEach
void clearMetaData() {
jobRepositoryTestUtils.removeJobExecutions();
}
@Test
void testJob() throws Exception {
var classLoader = ImportUserJobTest.class.getClassLoader();
var testInputFileName = URLDecoder.decode(
Objects.requireNonNull(classLoader.getResource(TEST_INPUT_FILE_NAME)).getFile(),
StandardCharsets.UTF_8
);
var expectedResultFileName = URLDecoder.decode(
Objects.requireNonNull(classLoader.getResource(EXPECTED_OUTPUT_FILE_NAME)).getFile(),
StandardCharsets.UTF_8
);
Job job = jobLauncherTestUtils.getJob();
assertThat(job).isNotNull()
.extracting(Job::getName)
.isEqualTo(IMPORT_USER_JOB_NAME);
JobParameters parameters = new JobParametersBuilder()
.addString(INPUT_FILE_NAME, testInputFileName)
.addString(OUTPUT_FILE_NAME, TEST_OUTPUT_FILE_NAME)
.toJobParameters();
JobExecution jobExecution = jobLauncherTestUtils.launchJob(parameters);
assertThat(jobExecution.getExitStatus().getExitCode()).isEqualTo("COMPLETED");
assertThat(new File(TEST_OUTPUT_FILE_NAME))
.hasSameTextualContentAs(new File(expectedResultFileName), StandardCharsets.UTF_8);
}
}
@@ -0,0 +1,45 @@
package ru.otus.example.springbatch.testchangelogs;
import com.github.cloudyrock.mongock.ChangeLog;
import com.github.cloudyrock.mongock.ChangeSet;
import com.github.cloudyrock.mongock.driver.mongodb.springdata.v3.decorator.impl.MongockTemplate;
import com.mongodb.client.MongoDatabase;
import ru.otus.example.springbatch.model.Person;
@ChangeLog(order = "001")
public class InitMongoDBDataChangeLog {
@ChangeSet(order = "000", id = "dropDB", author = "stvort", runAlways = true)
public void dropDB(MongoDatabase database){
database.drop();
}
@ChangeSet(order = "001", id = "initPersons", author = "stvort", runAlways = true)
public void initPersons(MongockTemplate template){
template.save(new Person("Тестовый Джон", 21));
template.save(new Person("Тестовый Игорь", 32));
template.save(new Person("Тестовый Дмитрий", 52));
template.save(new Person("Тестовый Михаил", 22));
template.save(new Person("Тестовый Герман", 33));
template.save(new Person("Тестовый Джон", 21));
template.save(new Person("Тестовый Игорь", 32));
template.save(new Person("Тестовый Дмитрий", 52));
template.save(new Person("Тестовый Михаил", 22));
template.save(new Person("Тестовый Герман", 33));
template.save(new Person("Тестовый Джон", 21));
template.save(new Person("Тестовый Игорь", 32));
template.save(new Person("Тестовый Дмитрий", 52));
template.save(new Person("Тестовый Михаил", 22));
template.save(new Person("Тестовый Герман", 33));
template.save(new Person("Тестовый Джон", 21));
template.save(new Person("Тестовый Игорь", 32));
template.save(new Person("Тестовый Дмитрий", 52));
template.save(new Person("Тестовый Михаил", 22));
template.save(new Person("Тестовый Герман", 33));
template.save(new Person("Тестовый Джон", 21));
template.save(new Person("Тестовый Игорь", 32));
template.save(new Person("Тестовый Дмитрий", 52));
template.save(new Person("Тестовый Михаил", 22));
template.save(new Person("Тестовый Герман", 33));
}
}
@@ -0,0 +1,43 @@
spring:
main:
allow-circular-references: true
batch:
job:
enabled: false
shell:
interactive:
enabled: false
command:
version:
enabled: false
datasource:
url: jdbc:h2:mem:testdb
driverClassName: org.h2.Driver
username: sa
password:
data:
mongodb:
host: localhost
port: 0
database: SpringBatchTestExampleDB
de:
flapdoodle:
mongodb:
embedded:
version: 4.0.2
mongock:
runner-type: "InitializingBean"
change-logs-scan-package:
- ru.otus.example.springbatch.testchangelogs
mongo-db:
write-concern:
journal: false
read-concern: local
@@ -0,0 +1,16 @@
Person(name=Ivan, age=24)
Person(name=John, age=25)
Person(name=Ivan, age=24)
Person(name=John, age=25)
Person(name=Ivan, age=24)
Person(name=Mary, age=25)
Person(name=Ivan, age=24)
Person(name=John, age=25)
Person(name=Sunny, age=24)
Person(name=John, age=25)
Person(name=Ivan, age=24)
Person(name=John, age=25)
Person(name=Ivan, age=24)
Person(name=John, age=25)
Person(name=Ivan, age=24)
Person(name=John, age=25)
@@ -0,0 +1,16 @@
Ivan,23
John,24
Ivan,23
John,24
Ivan,23
Mary,24
Ivan,23
John,24
Sunny,23
John,24
Ivan,23
John,24
Ivan,23
John,24
Ivan,23
John,24
1 Ivan 23
2 John 24
3 Ivan 23
4 John 24
5 Ivan 23
6 Mary 24
7 Ivan 23
8 John 24
9 Sunny 23
10 John 24
11 Ivan 23
12 John 24
13 Ivan 23
14 John 24
15 Ivan 23
16 John 24