Spring АйО
8.2K subscribers
292 photos
186 videos
382 links
Русскоязычное сообщество Spring-разработчиков.

Habr: bit.ly/433IK46
YouTube: bit.ly/4h3Ci0x
VK: bit.ly/4hF0OG8
Rutube: bit.ly/4b4UeX6
Яндекс Музыка: bit.ly/3EIizWy

Канал для общения: @spring_aio_chat
Download Telegram
Иногда полезно освежить в памяти базовые принципы фреймворка. В Spring Framework центральное место занимает Bean. В своем видео Джош Лонг рассказывает об истории Bean и его жизненном цикле.

#SpringTips #SpringBoot

📱 https://www.youtube.com/watch?v=Z5hxolai4Tk

Подписывайтесь:
😌@spring_aio
Please open Telegram to view this post
VIEW IN TELEGRAM
👍6🔥3👏2
🕐 Тонкое управление Scheduled задачами в Spring

Какими способами можно настроить время и условие запуска Scheduled задач в Spring?

Первый способ - через @Profile над сервисом, в котором объявлена задача, чтобы указать профили, в которых она должна (или не должна) запускаться.


java
@Profile("test,!prod")
class Teapot {
@Scheduled(cron = "0 15 10 15 * ?")
void makeTea() {
log.info("I'am a teapot.");
}
}


Но в таком варианте, мы управляем только условием запуска, но не можем настроить переодичность.

Другой вариант, это объявление cron expression в пропертях:


#application-test.properties
teapot.make_tea.cron=0 15 10 15 * ?



@Scheduled(cron = "${teapot.make_tea.cron}")
void makeTea() {
log.info("I'am a teapot.");
}


Но как тогда в таком случае отключить джобу? В качестве cron expression можно использовать дефис(-), что означает, что джобу запускать не нужно вовсе. И тогда мы обходимся без явного перечисления профилей в @Profile.


#application-prod.properties
teapot.make_tea.cron=-


Такой подход особо полезен при использовании spring-cloud-config, тогда нет необходимости делать передеплой приложения, чтобы выключить, или донастроить джобу. Нужно только не забыть повесить @RefreshScope на класс.

#SpringBoot #SpringTips
👍10👌5🔥3
🔧 Spring Data findAll антипаттерн

Spring Data Repository отличная концепция, позволяющая нам абстрагироваться от CRUD операций над domain entity. Достаточно объявить пустой интерфейс и унаследовать его, например, от ListCrudRepository или JpaRepository, если мы работем с JPA.


public interface OwnerRepository extends JpaRepository<Owner, Long> {
}


После этого нам сразу будут доступны основные методы работы с entity - save, update, findById, findAllById, findAll, и т.д. Но некоторые из этих методов могут привести к серъезным проблемам с производительностью и памятью, например метод findAll(). Давайте представим, что нам нужно найти всех Owner у которых есть животные c определенным именем и отсортировать по имени владельца. Поскольку мы находимся всего в одном шаге от вызова findAll() метода для «решения» этой проблемы, не пройдет много времени, пока кто-нибудь не предложит следующее решение:



public List<Owner> findOwnerByPetName(Collection<String> petNames) {
return ownerRepository.findAll()
.stream()
.filter(owner -> owner.getPets()
.stream()
.map(Pet::getName)
.anyMatch(petNames::contains)
)
.sorted(Comparator.comparing(Owner::getFirstName))
.toList();
}


Проблема здесь в том, что мы загрузили всю таблицу Post в память и потом начали фильтровать и сортировать данные, вместо того чтобы сделать это с помощью JPQL или SQL запроса за одно обращение к БД и загрузить в память только те данные, которые нам нужны в дальнейшем. Давайте посмотрим как мог бы выглядеть такой метод репозитория:


//derived method
List<Owner> findByPets_NameInOrderByFirstNameAsc(Collection<String> petNames);

//JPA query method
@Query("select o from Owner o left join o.pets pets where pets.name in ?1 order by o.firstName")
List<Owner> findOwnerByPetName(Collection<String> petNames);



Если у нас в репозитории есть метод findAll(), мы должны понимать что рано или поздно, по мере роста команды, им может кто-то воспользоваться. Возможно следует определять базовый интерфейс репозитория вашем проекте самостоятельно и наследовать его от org.springframework.data.repository.Repository и подконтрольно наполнять его методами.

#SpringData #SpringTips
👍235👀3👎2😴2
Spring Tip: Customizer интерфейсы для кастомизации бинов

Одной из главных причин популярности Spring Boot является его способность автоматически конфигурировать (auto-configuration) множество компонентов, существенно упрощая жизнь разработчикам. Однако иногда возникает необходимость слегка подправить настройки этих компонентов без отказа от всех преимуществ автоматической конфигурации.

Предположим, вам нужно кастомизировать настройки кэша в вашем приложении. Вместо того чтобы полностью переопределять CacheManager, вы можете использовать Customizer интерфейс, чтобы внести необходимые изменения:


@Bean
CacheManagerCustomizer<ConcurrentMapCacheManager> cacheManagerCustomizer() {
return cacheManager -> cacheManager.setAllowNullValues(false);
}


Аналогичным образом можно настроить и любые другие компоненты:


//Кастомизация свойств Hibernate
@Bean
HibernatePropertiesCustomizer hibernatePropertiesCustomizer() {
return properties -> properties.put("hibernate.integrator_provider",
(IntegratorProvider) () -> List.of(new BeanValidationIntegrator()));
}

//Кастомизация Jackson2ObjectMapperBuilderCustomizer
@Bean
Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
return builder -> {
builder.serializationInclusion(JsonInclude.Include.NON_EMPTY);
builder.featuresToEnable(
SerializationFeature.WRITE_ENUMS_USING_TO_STRING,
DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY);
};
}


Но бывает и такое, что подходящего Customizer интерфейса просто нет. В таком случае, можно использовать BeanPostProcessor для кастомизации уже инициализированных бинов:


//Кастомизация springLiquibase бина
@Bean
BeanPostProcessor liquibaseBeanPostProcessor() {
return new BeanPostProcessor() {
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (bean instanceof SpringLiquibase springLiquibase) {
springLiquibase.setContexts("my-context");
}
return bean;
}
};
}


#SpringBoot #SpringTips
Please open Telegram to view this post
VIEW IN TELEGRAM
👍38🔥99
👨‍💻 Spring Tips: работаем со свойствами

Более миллиона раз разработчики обращались к вопросу на StackOverflow о том, как получить значение из application.properties. Возможно, вы удивитесь (хотя я очень сильно сомневаюсь в этом), но отмеченный как верный ответ с @Value – это не единственный способ. И далеко не всегда самый удобный.

Способ №1: @Value

Аннотация @Value используется для инъекции отдельных значений из файла конфигурации. Например:


@Value("${property.name}")
private String propertyName;


Пожалуй, самый простой, но не самый поддерживаемый вариант, из-за отсутствия группировки и большого количества дублирующего кода, когда значений, с которыми надо работать, становится довольного много.

Способ №2: @ConfigurationProperties

Аннотация @ConfigurationProperties используется для группировки связанных свойств в объект класса. Свойства могут настраиваться через .properties и .yaml файлы, переменные окружения и т.д.

Типичное использование @ConfigurationProperties может выглядеть следующим образом:


@ConfigurationProperties(prefix = "app")
@Component
@Getter
@Setter
public class ApplicationProperties {
private String name;
}

@SpringBootTest(properties = "app.name=my-app")
class ApplicationPropertiesTest {

@Autowired
private ApplicationProperties applicationProperties;

@Test
void appName() {
var appName = applicationProperties.getName();
assertEquals("my-app", appName);
}
}


Да, нужно будет создать отдельный класс, зато преимуществ у этого подхода относительно @Value довольно много. Перечислим некоторые из них:

– Группировка по префиксу
– Возможность иерархического представления свойств
– Это бин и этим всё сказано :)
@ConfigurationPropertiesBinding
– Кто сможет назвать ещё парочку преимуществ в комментариях?)

Отдельно отметим @ConfigurationPropertiesBinding. Допустим, нам нужно добавить новое свойство version, которое будет представлять класс Version для обработки семантического версионирования (major.minor.patch).


@ConfigurationProperties(prefix = "app")
@Component
@Getter
@Setter
public class ApplicationProperties {
private String name;
private Version version;
}


Для преобразования строкового значения из конфигурации в объект класса Version нам потребуется конвертор, который как раз будет отмечен аннотацией @ConfigurationPropertiesBinding:


@ConfigurationPropertiesBinding
@Component
class SemVerPropertyConverter implements Converter<String, Version> {

@Override
public Version convert(String source) {
return StringUtils.hasLength(source) ? Version.parse(source) : null;
}
}


Пример использования:


@SpringBootTest(properties = {
"app.name=my-app",
"app.version=1.1.0"
})
class ApplicationPropertiesTest {

@Autowired
private ApplicationProperties applicationProperties;

@Test
void appVersion() {
Version version = applicationProperties.getVersion();
assertEquals(1, version.getMajorVersion());
assertEquals(1, version.getMinorVersion());
assertEquals(0, version.getPatchVersion());
}
}


Подробнее про @ConfigurationProperties и @ConfigurationPropertiesBinding вы можете прочитать в документации.

Способ №3: Environment

А если вы хотите обойтись без специальных аннотаций, можно использовать Environment:


@Autowired
private Environment env;

public void someMethod() {
String property = env.getProperty("property.name");
}


#SpringBoot #SpringTips
Please open Telegram to view this post
VIEW IN TELEGRAM
👍19🔥97🤔1🤯1
🛡 Spring Tips: Service Layer Validation

Валидация данных – ключевой аспект любого приложения. В Spring она часто используется в параметрах методов @RestController, например:


@RestController
@RequestMapping("/api/products")
public class ProductController {

@GetMapping("/search")
public ResponseEntity<List<Product>> searchProducts(@RequestParam @NotNull @Size(min = 3, max = 50) String name,
@RequestParam @NotNull @Min(0) @Max(10000) Double price) {
// Логика поиска продуктов
List<Product> products = productService.search(name, price);
return new ResponseEntity<>(products, HttpStatus.OK);
}
}


Валидация в сервисном слое

Валидация помогает гарантировать, что данные, поступающие в приложение, соответствуют требованиям. Для валидации в сервисном слое нужно добавить аннотацию @Validated над сервисом:


@Service
@Validated
public class EmailService {
public void send(@Email String email,
@Length(max = 10) String subject,
@NotBlank String body) {
// бизнес-логика
}
}


Теперь метод send вызовет ошибку, если данные не проходят валидацию:


emailService.send(
"i am not email",
"I am too loooooooooong",
""
);



jakarta.validation.ConstraintViolationException: send.body: must not be blank, send.email: must be a well-formed email address, send.subject: length must be between 0 and 10
...


Валидация DTO в методах сервиса

Чтобы не дублировать поля в разных методах, разработчики часто используют DTO. Аннотации валидации применимы и здесь:


public record EmailRequest(
@Email String email,
@Length(max = 10) String subject,
@NotBlank String body
) {
}


Но в этом случае помимо @Validated над классом, нужно также не забыть добавить @Valid перед типом параметра в методе:


@Service
@Validated
public class EmailService {
public void sendBatch(
@Valid List<EmailRequest> requests
) {
// do work
}
}


Следующий код вызовет ошибку:


emailService.sendBatch(
List.of(
new EmailRequest("not email", "test", "Hello"),
new EmailRequest("alex@spring.aio", "I am too loooooooooong", "")
)
);



jakarta.validation.ConstraintViolationException: sendBatch.requests[1].subject: length must be between 0 and 10, sendBatch.requests[1].body: must not be blank, sendBatch.requests[0].email: must be a well-formed email address


Валидация элементов коллекций

Кстати, точно также можно валидировать и элементы коллекций, а также ключи и значения в Map:


@Service
@Validated
public class EmailService {
public void search(
Map<@NotBlank String, @NotBlank String> searchParams
) {
// do work
}
}


Следующий код вызовет ошибку:


searchService.search(Map.of("", ""));



jakarta.validation.ConstraintViolationException: search.searchParams<K>[].<map key>: must not be blank, search.searchParams[].<map value>: must not be blank


#SpringBoot #SpringTips #Validation
Please open Telegram to view this post
VIEW IN TELEGRAM
👍44🔥132👌1
👨‍💻 Spring Tips: Кастомные реализации репозиториев

Spring Data упрощает создание запросов, но стандартные методы не всегда подходят под конкретные задачи. В таких случаях мы можем создать собственные реализации методов репозиториев. Например, если мы хотим, чтобы запрос формировался динамически на основе фильтра и возвращал DTO с меньшим количеством полей, нам понадобится создать fragment interface и кастомный метод с собственной реализацией.


//Кастомный интерфейс
public interface CustomizedUserRepository {
List<UserDto> findAllUsers(UserFilter filter);
}

//Реализация нашего интерфейса, которая будет использоваться Spring'ом
public class CustomizedUserRepositoryImpl implements CustomizedUserRepository {
private final EntityManager em;

public CustomizedUserRepositoryImpl(JpaContext jpaContext) {
em = jpaContext.getEntityManagerByManagedType(User.class);
}

//Реализация нашего метода
@Override
public List<UserDto> findAllUsers(UserFilter filter) {
var cb = em.getCriteriaBuilder();
var query = cb.createQuery(UserDto.class);

var root = query.from(User.class);
var emailPath = root.<String>get(User_.EMAIL);
var usernamePath = root.<String>get(User_.USERNAME);
query.multiselect(root.get(User_.ID), emailPath, usernamePath);

var predicates = new ArrayList<Predicate>();

var email = filter.email();
if (StringUtils.hasLength(email)) {
predicates.add(cb.like(cb.lower(emailPath), "%" + email.toLowerCase() + "%"));
}

var username = filter.username();
if (StringUtils.hasLength(username)) {
predicates.add(cb.like(cb.lower(usernamePath), "%" + username.toLowerCase() + "%"));
}

query.where(predicates.toArray(new Predicate[]{}));
return em.createQuery(query).getResultList();
}
}

//Использование кастомного интерфейса
interface UserRepository extends CrudRepository<User, Long>, CustomizedUserRepository {}


Теперь при инжекции UserRepository, кастомный метод будет доступен для вызова:


List<UserDto> users = userRepository.findAllUsers(new UserFilter("Maksim", null));


Важные замечания

1. CustomizedUserRepositoryImpl — полноценный Spring-бин, поддерживающий инжекцию других бинов и специфическую функциональность (AOT, Lifecycle Callbacks и т.д.).

2. Инжекция UserRepository может привести к циклической зависимости. Чтобы избежать этого, его можно получить через ApplicationContext.getBean().

3. Spring пытается автоматически обнаружить пользовательские fragment интерфейсы, если классы следуют соглашению об именовании с постфиксом Impl. Модифицировать значение по умолчанию можно через атрибут аннотации @EnableJpaRepositories:


@EnableJpaRepositories(repositoryImplementationPostfix = "MyPostfix")


4. Репозитории могут включать несколько пользовательских реализаций, которые имеют более высокий приоритет, чем базовая реализация, что позволяет переопределять базовые методы. Например, создадим кастомный интерфейс и переопределим метод save:


public interface CustomizedSave<T> {
<S extends T> S save(S entity);
}

class CustomizedSaveImpl<T> implements CustomizedSave<T> {
@Override
public <S extends T> S save(S entity) {
// наша кастомная реализация
}
}


Теперь, если мы объявим следующий репозиторий:


interface UserRepository extends CrudRepository<User, Long>, CustomizedSave<User> {}


то при вызове метода userRepository.save(user) будет использован метод из нашей реализации CustomizedSaveImpl.

5. В примерах выше мы рассматривали Spring Data JPA, но эта концепция поддерживается для всех модулей Spring Data (MongoDB, Redis, JDBC и т.д.).

Подробнее про реализацию кастомных репозиториев читайте в документации.

#SpringBoot #SpringTips #CustomRepository
Please open Telegram to view this post
VIEW IN TELEGRAM
👍24🔥8👌3
Spring Tips: Переопределение и дополнение свойств для Spring Boot тестов

При разработке на Spring Boot часто возникает необходимость переопределять конфигурационные свойства для тестов. Представим, что у нас есть application.properties в директории src/main/resources со следующими свойствами:


spring.application.name=properties
spring.jpa.open-in-view=false


Мы хотим изменить spring.application.name и добавить новое свойство server.port в тестах. Интуитивно разработчики создают application.properties в src/test/resources, ожидая, что он дополнит основной файл. Однако это не так (подробное объяснение можно найти здесь). Рассмотрим несколько способов переопределения свойств в тестах.

1. @ActiveProfiles

Создайте файл application-test.properties в src/test/resources и активируйте его в тестах.


@SpringBootTest
@ActiveProfiles("test")
class ActiveProfileTest {

@Autowired
private Environment env;

@Test
void test() {
Assertions.assertEquals("new-name", env.getProperty("spring.application.name"));
Assertions.assertEquals(false, env.getProperty("spring.jpa.open-in-view", Boolean.class));
Assertions.assertEquals(8099, env.getProperty("server.port", Integer.class));
}
}


2. Директория config

Spring загружает конфигурационные файлы в определенном порядке. Файлы в src/test/resources/config имеют приоритет и переопределяют свойства из src/main/resources. Более подробно ознакомиться с тем, по какому принципу Spring загружает свойства можно в документации.

3. @DynamicPropertySource

Используйте динамическую подмену свойств, если значения известны только во время выполнения.


@SpringBootTest
class DynamicPropertySourceTest {

@Autowired
private Environment env;

@DynamicPropertySource
static void properties(DynamicPropertyRegistry registry) {
registry.add("spring.application.name", () -> "new-name");
registry.add("server.port", () -> 8099);
}

@Test
void test() {
Assertions.assertEquals("new-name", env.getProperty("spring.application.name"));
Assertions.assertEquals(false, env.getProperty("spring.jpa.open-in-view", Boolean.class));
Assertions.assertEquals(8099, env.getProperty("server.port", Integer.class));
}
}


4. @TestPropertySource с параметром locations

Укажите путь к *.properties файлу с нужными значениями.


@SpringBootTest
@TestPropertySource(locations = "classpath:test.properties")
class PropertiesSourceLocationTest {

@Autowired
private Environment env;

@Test
void test() {
Assertions.assertEquals("new-name", env.getProperty("spring.application.name"));
Assertions.assertEquals(false, env.getProperty("spring.jpa.open-in-view", Boolean.class));
Assertions.assertEquals(8099, env.getProperty("server.port", Integer.class));
}
}


Продолжение в комментариях

#SpringBoot #SpringTips
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥19👍94
🦥 Spring Tips: Аннотация @Lazy

По умолчанию Spring инициализирует все sigleton-бины во время запуска приложения. Аннотация @Lazy позволяет изменить это поведение, инициализируя бины только по мере необходимости. Используя аннотацию @Lazy можно существенно снизить потребление памяти и уменьшить время запуска приложения, отметив бины, которые используются в приложении не так часто и занимают немало памяти.

Способы применения

Чтобы сделать бин лениво инициализируемым, нужно отметить его аннотацией @Lazy в месте его объявления:


@Lazy
@Component
public class MyComponent {
//...
}

@Configuration
@Lazy
public class LazyConfig {
// Все объявленные в этом конфиге бины будут ленивыми
}

@Configuration
public class AppConfig {
//Этот конкретный бин будет ленивым
@Bean
@Lazy
public MyBean myBean() {
return new MyBean();
}
}


А также в месте его инжекции:


@Service
public class MyService {
private final MyBean myBean;
private final MyComponent myComponent;

public MyService(@Lazy MyBean myBean, @Lazy MyComponent myComponent) {
this.myBean = myBean;
this.myComponent = myComponent;
}
}


Если не воспользоваться аннотацией @Lazy в месте объявления бина или в месте его инжекции, то он НЕ БУДЕТ ленивым.

Ленивая инициализация контекста по умолчанию

Чтобы сделать инициализацию ленивой для всего контекста, можно добавить следущее свойство в конфигурационный файл:


spring.main.lazy-initialization=true


Однако стоит быть осторожным с этим подходом. Ошибки инициализации могут возникнуть не во время запуска приложения, а позже – в рантайме, что усложнит их обнаружение и исправление (что в целом справедливо и при ленивой инициализации лишь некоторых бинов). Но если вы всё таки решите использовать глобальную ленивую инициализацию, можно исключить определённые бины, инициализируя их жадно (во время запуска приложения) с помощью @Lazy(false):


@Service
@Lazy(false)
public class MyService {
// код
}


А много ли бинов лениво инициализируется на вашем проекте? Может быть вообще все? Поделитесь своим опытом в комментариях, будет интересно почитать!

#SpringBoot #SpringTips
Please open Telegram to view this post
VIEW IN TELEGRAM
👍296🔥6
🛡 Кастомные валидаторы в Spring Boot: Как сделать валидацию под себя

Валидация входных данных — важная часть любой системы, обеспечивающая целостность данных и предотвращение ошибок. Иногда стандартные аннотации Jakarta Bean Validation (например, @NotNull, @Size, @Pattern) не покрывают все наши потребности, и нам приходится писать свои собственные валидаторы. В этом посте мы разберем два подхода к созданию кастомных валидаторов в Spring Boot, а также узнаем, как настроить отображение ошибки валидации.

1. Кастомные валидации с мета-аннотациями

Если стандартных аннотаций не хватает, можно создать свою собственную аннотацию для валидации. Это удобно, если вам нужно сочетать несколько стандартных аннотаций в одной или переиспользовать уже имеющиеся аннотации. Например, давайте создадим аннотацию @CardNumber, которая проверяет, соответствует ли строка формату номера кредитной карты.


@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Pattern(regexp = "([0-9]{4}-){3}[0-9]{4}$", message = "Invalid credit card number")
@Constraint(validatedBy = {})
public @interface CardNumber {
String message() default "Invalid credit card number";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}


В этом примере аннотация @CardNumber использует @Pattern для проверки формата номера кредитной карты. Такой подход упрощает код, потому что вся логика валидации сконцентрирована в одной аннотации.


@PostMapping("/card")
void checkCardNumber(@RequestParam @Valid @CardNumber String cardNumber) {
}



@Test
public void validCardNumber() throws Exception {
mockMvc.perform(post("/card")
.param("cardNumber", "1111-1111-1111-1111"))
.andExpect(status().isOk())
.andDo(print());
}

@Test
public void invalidCardNumber() throws Exception {
mockMvc.perform(post("/card")
.param("cardNumber", "1111-1111-1111"))
.andExpect(status().is(400))
.andDo(print());
}


2. Реализация собственных валидаторов

Когда стандартные аннотации не подходят, можно создать собственные валидаторы. Для этого нужно реализовать интерфейс ConstraintValidator.

Предположим, нам нужно валидировать пароли с определенными требованиями. Для этого создадим аннотацию @ValidPassword и валидатор PasswordValidator.


@Target({ElementType.FIELD, ElementType.METHOD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordValidator.class)
public @interface ValidPassword {
String message() default "Invalid password";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
int minLength() default 8;
String specialChars() default "!@#$%^&*()";
}

public class PasswordValidator implements ConstraintValidator<ValidPassword, String> {
private int minLength;
private String specialChars;

@Override
public void initialize(ValidPassword constraintAnnotation) {
this.minLength = constraintAnnotation.minLength();
this.specialChars = constraintAnnotation.specialChars();
}

@Override
public boolean isValid(String password, ConstraintValidatorContext context) {
if (password == null) {
return false;
}

boolean hasUpperCase = !password.equals(password.toLowerCase());
boolean hasLowerCase = !password.equals(password.toUpperCase());
boolean hasDigit = password.chars().anyMatch(Character::isDigit);
boolean hasSpecialChar = password.chars().anyMatch(ch -> specialChars.indexOf(ch) >= 0);
boolean isLongEnough = password.length() >= minLength;

return hasUpperCase && hasLowerCase && hasDigit && hasSpecialChar && isLongEnough;
}
}


В данном примере аннотация @ValidPassword проверяет, что пароль содержит и верхний, и нижний регистр, цифры, специальные символы и имеет достаточную длину. Логика валидации инкапсулирована в PasswordValidator.

Продолжение в комментариях 👇

#SpringBoot #SpringTips
Please open Telegram to view this post
VIEW IN TELEGRAM
👍317🔥4