Библиотека Java разработчика
10.5K subscribers
1.17K photos
594 videos
58 files
1.49K links
📚 Лайфхаки, приёмы и лучшие практики для Java-разработчиков. Всё, что ускорит код и прокачает навыки. Java, Spring, Maven, Hibernate.


По всем вопросам @evgenycarter

РКН clck.ru/3KoGeP
Download Telegram
🔮 Pattern Matching в Switch: Типизируй это!

Помните этот бесконечный кошмар, когда вам приходит Object, и нужно понять, что внутри?
Раньше мы писали "лестницу" из if-else и instanceof с кучей ручного кастинга (приведения типов).

Было (Боль и слезы):


Object obj = getUnknownObject();

if (obj instanceof String) {
String s = (String) obj; // Ручной каст
System.out.println("Строка: " + s.length());
} else if (obj instanceof Integer) {
Integer i = (Integer) obj; // Опять каст
System.out.println("Число: " + i);
} else if (obj instanceof Long) {
// ... и так до бесконечности
}



Стало (Java 21 LTS):
Теперь switch умеет проверять типы! Больше никаких instanceof и ручных приведений. Переменная создается прямо в кейсе.


switch (obj) {
case String s -> System.out.println("Строка: " + s.length());
case Integer i -> System.out.println("Число: " + i);
case Long l -> System.out.println("Длинное число: " + l);
default -> System.out.println("Непонятно что");
}



🛡 Guarded Patterns (Охрана в кейсах)

Но это еще не всё. Часто бывает, что тип нам подходит, но нужно проверить еще и значение.
Раньше пришлось бы ставить if внутри case. Теперь у нас есть ключевое слово when.


switch (obj) {
// Попадет сюда, ТОЛЬКО если это строка И она длиннее 10 символов
case String s when s.length() > 10 ->
System.out.println("Длинная строка: " + s);

// Любая другая строка
case String s ->
System.out.println("Короткая строка: " + s);

case Integer i ->
System.out.println("Число: " + i);

default -> {}
}



👻 А как же Null?

В старом switch, если передать null, мы мгновенно получали NullPointerException.
В новом switch можно (и нужно!) обрабатывать null легально:


switch (obj) {
case String s -> System.out.println("Это строка");
case null -> System.out.println("Пришел null!"); // Никакого NPE
default -> System.out.println("Что-то другое");
}




Это не просто "сахар". Это изменение подхода к полиморфизму.
Если раньше логика часто размазывалась по методам классов (animal.makeSound()), то теперь можно собирать логику обработки разных типов в одном месте, что часто бывает удобнее при написании бизнес-логики (например, обработка разных типов ивентов или DTO).

🔥 Итог

🔴Switch теперь принимает любые объекты.
🔴Кастинг происходит автоматически (case String s).
🔴Можно уточнять условия через when.
🔴Можно безопасно ловить null.

#PatternMatching #NewJava #CleanCode

📲 Мы в MAX

👉@BookJava
Please open Telegram to view this post
VIEW IN TELEGRAM
👍7
🎯 Открытый урок «Сетевой чат на C#».

🗓 22 января в 20:00 МСК
🆓 Бесплатно. Урок в рамках старта курса «C# Developer».

На вебинаре:
✔️ Рассмотрим написание сетевого приложения на C#.
✔️ Мы реализуем простые клиент и сервер с помощью одного из сетевых протоколов.
✔️Также затронем темы многопточности и асинхронности

Кому будет полезно:
- Вебинар будет полезен начинающим разработчикам, желающим разобраться в сетевом и многопочном\асинхронном программировании.

Что вы получите:
- По итогам вебинара смогут проектировать сетевые приложения.
- Получат представление о работе сетевых протоколов, и многопоточности\асинхронности в приложениях.
- На практике попробуют разработать такое приложение.

🔗 Ссылка на регистрацию: https://vk.cc/cTqyyY

Реклама. ООО «Отус онлайн-образование», ОГРН 1177746618576
Please open Telegram to view this post
VIEW IN TELEGRAM
🔒 Sealed Classes: Архитектурный фейсконтроль

В ООП всегда была проблема с наследованием. Либо ваш класс открыт для всех («Наследуйся кто хочет!»), либо он закрыт наглухо (final).
Но что, если я хочу, чтобы мой класс Shape (Фигура) могли наследовать только Circle и Square, и больше никто?

Раньше это решалось костылями (пакетная видимость, скрытые конструкторы). В Java 17 появился официальный механизм: Sealed Classes.

🚧 Как это работает?

Вы помечаете класс (или интерфейс) ключевым словом sealed и после слова permits перечисляете тех, кому "можно".


// Родитель: Разрешает наследование ТОЛЬКО этим двум классам
public sealed interface Payment
permits CreditCard, Cash {
}



Теперь, если Вася из соседнего отдела попытается написать class Crypto extends Payment, компилятор ударит его по рукам. 🚫

🚦 Правило трех дорог

Наследники запечатанного класса обязаны выбрать свою судьбу. Они должны иметь один из трех модификаторов:

1. final - Цепочка наследования обрывается. Дальше наследовать нельзя.

public final class Cash implements Payment { ... }




2. sealed - Иерархия продолжается, но снова под контролем.

public sealed class CreditCard implements Payment permits Visa, MasterCard { ... }




3. non-sealed - "Открываем шлюзы". Дальше от этого класса может наследоваться кто угодно (возврат к старому поведению Java).

public non-sealed class DebitCard implements Payment { ... }





🧩 Главная фишка: Комбо со Switch

Зачем это нужно, кроме запретов? Ради безопасности.
Когда вы используете sealed иерархию в новом switch, компилятор знает все возможные варианты.

Вам больше не нужно писать ветку default!


// Метод обработки платежа
String process(Payment p) {
return switch (p) {
case CreditCard c -> "Processing card: " + c.getNumber();
case Cash c -> "Processing cash amount: " + c.getAmount();
// default НЕ НУЖЕН! Компилятор знает, что третьго не дано.
};
}



Если завтра вы добавите в permits новый класс Crypto, код перестанет компилироваться, пока вы не обработаете этот новый кейс в свитче.
Это спасает от багов, когда в бизнес-логику добавили новый тип, а обработчики обновить забыли.

🔥 Итог

Sealed Classes + Records + Switch Pattern Matching = 💎
Это "Святая Троица" современной Java. Используйте запечатанные классы для моделирования доменной области, где количество вариантов конечно и известно заранее (статусы заказа, типы платежей, виды пользователей).

#Java17 #SealedClasses #Architecture #CleanCode

📲 Мы в MAX

👉@BookJava
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥81
🧵 Виртуальные потоки: Революция производительности

Представьте, что вы строите высоконагруженный сервер. Раньше у вас было два пути:

1. Классика (Thread): Простой код, но один поток весит ~2 Мб памяти. Создадите 5,000 потоков - сервер упадет с OutOfMemoryError.
2. Асинхронность (WebFlux/Netty): Сервер держит 100k соединений, но код превращается в лапшу из callbacks и CompletableFuture, которую невозможно отлаживать.

В Java 21 появились Виртуальные потоки. Они объединяют простоту первого подхода и производительность второго.

🪶 В чем магия?

Классический поток Java (Platform Thread) привязан 1-к-1 к потоку операционной системы (OS Thread). Это дорогой ресурс.

Виртуальный поток, это просто объект в куче (heap) JVM. Он не привязан к ОС намертво.

🔴Вес: Несколько килобайт (вместо мегабайт).
🔴Количество: Можно создать миллион виртуальных потоков на обычном ноутбуке.

⚙️ Как это работает (Carrier Threads)

Под капотом работает схема Mount/Unmount:

1. JVM запускает небольшой пул обычных потоков ОС (называются Carrier Threads, обычно их число = числу ядер CPU).
2. Ваш виртуальный поток "садится верхом" на Carrier-поток и выполняет код.
3. ⚠️ Самое важное: Как только ваш код блокируется (ждет ответа от БД, читает файл, делает Thread.sleep), JVM снимает виртуальный поток с ядра.
4. Поток ОС освобождается и тут же берет в работу другой виртуальный поток.

Итог: Ядра процессора молотят на 100%, никогда не простаивая в ожидании ввода-вывода.

💻 Код: Найди 1 отличие

API практически не изменился. Вам не нужно учить новые фреймворки.


// Старый способ (Тяжелый поток ОС)
Thread.ofPlatform().start(() -> {
System.out.println("Я ем много памяти!");
});

// Новый способ (Легкий виртуальный поток)
Thread.ofVirtual().start(() -> {
System.out.println("Я ничего не вешу!");
});

// Использование с ExecutorService (для старого кода)
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 1_000_000).forEach(i -> {
executor.submit(() -> {
Thread.sleep(1000); // Блокировка теперь БЕСПЛАТНАЯ
return i;
});
});
}
// Этот код запустит миллион задач за секунду, не положив сервер.



⚰️ Конец Reactive Programming?

Многие эксперты говорят: Да.
Смысл использования сложных реактивных библиотек (RxJava, Reactor) был в том, чтобы не блокировать потоки. Виртуальные потоки делают блокировку дешевой.
Теперь вы можете писать простой, последовательный код:
var user = db.findUser();
var data = http.sendRequest(user);
...и он будет работать так же эффективно, как сложный асинхронный код.

⚠️ Когда НЕ использовать?

Виртуальные потоки идеальны для I/O задач (ждать сеть, ждать диск).
Они бесполезны для CPU-Intensive задач (майнинг, шифрование, обработка видео). Если поток не ждет, а считает, он занимает поток ОС, и виртуалка тут не поможет.

🔥 Итог
Project Loom вернул нам девиз: "Один запрос - Один поток".
Больше никаких пулов потоков с ограниченным размером. Просто создавайте новый виртуальный поток для каждой задачи.

#Java #Loom #VirtualThreads #Concurrency #HighLoad

📲 Мы в MAX

👉@BookJava
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥113
🍃 Spring Boot: Магия или Логика? (IoC & Beans)

Когда вы запускаете Spring-приложение, происходит магия: все нужные объекты создаются сами, базы данных подключаются, сервер стартует.
Но за этой магией стоит четкий механизм - IoC Container (Inversion of Control / Инверсия управления).

📦 Что такое Application Context?

Представьте Spring как огромный завод.

🔴Context (Контейнер) - это сам завод. Он управляет жизненным циклом объектов.
🔴Bean (Бин) - это любая деталь (объект), которую этот завод создал и хранит у себя на складе.

Суть IoC:

🔴Обычный подход: Вы сами управляете объектами (Service s = new Service()). Вы - главный.
🔴Spring подход: Вы отдаете управление фреймворку. "Спринг, создай мне сервис и дай его, когда он понадобится". Spring - главный.

🏷 Как сделать Бин? (Аннотации)

Чтобы Spring узнал про ваши классы, их нужно пометить.

1. @Component — Самая базовая аннотация. "Эй, Спринг, это бин, управляй им!".
2. @Service - Тот же @Component, но семантически говорит: "Здесь бизнес-логика".
3. @Repository - Тот же @Component, но для работы с БД (ловит специфичные ошибки баз данных).
4. @Controller / @RestController - Для обработки HTTP-запросов.
5. @Configuration + @Bean - Используется, когда нужно создать бин из чужого класса (библиотеки), код которого вы не можете пометить аннотацией @Component.

💉 Dependency Injection (DI)

Главная фишка. Как один бин попадает внутрь другого?
Например, UserService нуждается в UserRepository.

Способ 1: Через поле (Field Injection)


@Service
public class UserService {
@Autowired // ⚠️ Не рекомендуется!
private UserRepository repository;
}



Почему плохо: Невозможно протестировать (как подсунуть мок?), скрытые зависимости, возможен NullPointerException.

Способ 2: Через конструктор (Constructor Injection)
Золотой стандарт современного Spring.


@Service
public class UserService {
private final UserRepository repository;

// @Autowired здесь не обязателен (в новых версиях Spring)
public UserService(UserRepository repository) {
this.repository = repository;
}
}



Плюсы: Поле final (неизменяемое), легко тестировать (можно передать любой репозиторий в конструктор), сразу видно все зависимости класса.

Лайфхак: Lombok

Чтобы не писать конструктор руками, используйте Lombok:


@Service
@RequiredArgsConstructor // Генерирует конструктор для final полей
public class UserService {
private final UserRepository repository; // Всё внедрится само!
}



🔥 Итог

Spring, это просто "мешок с объектами" (Context), которые он создает сам и связывает друг с другом (DI).

🔴Забудьте про new Service().
🔴Используйте внедрение через конструктор.
🔴Помечайте классы правильными аннотациями (@Service, @Repository).

#SpringBoot #Java #IoC #DI #SpringTips

📲 Мы в MAX

👉@BookJava
Please open Telegram to view this post
VIEW IN TELEGRAM
🔥5👍2🤡2
⌨️ Открытый урок «Неожиданное введение в Spring Context».

🗓 22 января в 20:00 МСК

🆓 Бесплатно. Урок в рамках старта курса «Разработчик на Spring Framework».

На очередном открытом уроке курса "Разработчик на Spring Framework", мы на примере своего приложения попробуем реализовать свой IoC-контейнер, которой отдаленно будет напоминать Spring Context

Кому будет интересно:
Начинающим Java-бэкенд-разработчикам.

Результаты после вебинара:
Поймете общую идею IoC/Spring IoC.

🔗 Ссылка на регистрацию: https://vk.cc/cTwZTO

Реклама. ООО «Отус онлайн-образование», ОГРН 1177746618576
2👍1
🧙‍♂️ Spring Boot: Магия под капотом (Starters & AutoConfig)

Вы когда-нибудь задумывались: почему вы просто добавляете одну строчку в pom.xml, пишете main метод, и у вас волшебным образом поднимается Tomcat, настраивается JSON-конвертер и подключается логирование?

За этим стоят два кита Spring Boot: Starters и AutoConfiguration.

1️⃣ Starters (Стартеры) - "Всё включено"

В старом Spring, чтобы сделать веб-приложение, нужно было вручную найти версии для Spring MVC, Tomcat, Jackson, Validation API... и молиться, чтобы они были совместимы.

Starter — это готовый набор зависимостей (dependencies), собранный в один пакет. Это как "Комбо-обед" в ресторане.

🔴Хотите Веб? Добавляете spring-boot-starter-web.
* Внутри: Tomcat + Spring MVC + Jackson + Logback.


🔴Хотите Тесты? Добавляете spring-boot-starter-test.
*Внутри: JUnit + Mockito + AssertJ + Hamcrest.



Вам больше не нужно думать о версиях библиотек. Spring Boot следит за "BOM" (Bill of Materials) и гарантирует, что все версии внутри стартера дружат друг с другом.

2️⃣ AutoConfiguration - "Умный детектив"

Это мозг фреймворка. Когда приложение запускается, Spring Boot начинает сканировать ваш classpath (все подключенные библиотеки jar).

Он рассуждает примерно так:

1. "Так, я вижу, что в зависимостях есть класс H2Driver?" "Значит, программист хочет базу данных. Создам-ка я ему бин DataSource с настройками для H2!"
2. "Я вижу классы Tomcat и Spring MVC?" "Значит, нужно поднять встроенный веб-сервер на порту 8080 и настроить DispatcherServlet."
3. "О, программист сам создал свой бин DataSource?" "Окей, тогда я отступаю и свою автоконфигурацию не применяю."

Вся эта логика держится на аннотациях @Conditional...:

🔴@ConditionalOnClass: Создать бин, если найден класс X.
🔴@ConditionalOnMissingBean: Создать бин, ТОЛЬКО если программист не создал такой же сам.

⚙️ Главная кнопка: @SpringBootApplication

Вы вешаете эту аннотацию над main классом. На самом деле это "матрешка", внутри которой спрятаны 3 другие аннотации:


@SpringBootConfiguration // Говорит: "Это конфигурационный класс"
@EnableAutoConfiguration // Говорит: "ВКЛЮЧИ МАГИЮ!" (запусти сканирование classpath)
@ComponentScan // Говорит: "Ищи пользовательские бины в этом пакете"
public @interface SpringBootApplication { ... }



🕵️‍♂️ Pro-Tip: Как увидеть магию?

Иногда автоконфигурация мешает, или вы не понимаете, почему Spring решил подключить ту или иную базу.
Добавьте в application.properties одну строчку:


debug=true



При запуске в консоль вывалится Condition Evaluation Report. Там будет честно написано:

🔴 Positive Matches: Что Spring настроил сам (и почему).
🔴 Negative Matches: Что Spring проигнорировал (потому что не нашел нужных классов или вы перекрыли это своим бином).

🔥 Итог

🔴Starters экономят время на подбор зависимостей.
🔴AutoConfiguration экономит время на написание рутинных конфигов.
🔴Spring Boot работает по принципу Convention over Configuration (Соглашение важнее конфигурации): "Я дам тебе лучшие настройки по умолчанию, но ты всегда можешь их изменить".

#SpringBoot #Java #Starters #AutoConfig

📲 Мы в MAX

👉@BookJava
Please open Telegram to view this post
VIEW IN TELEGRAM
👍5
🎮 Анатомия REST Controller: Входящие и Исходящие

Раньше, чтобы вернуть JSON, нужно было танцевать с бубном. В Spring Boot это делается "из коробки" благодаря библиотеке Jackson, которая тихо работает в фоне.

1️⃣ @RestController vs @Controller

Это первый вопрос на собеседовании.

🔴@Controller: Олдскул. Используется, когда мы возвращаем HTML-страницы (Thymeleaf, JSP). Чтобы вернуть JSON, нужно над каждым методом вешать @ResponseBody.
🔴@RestController: Современный стандарт для REST API.
🔴Это просто @Controller + @ResponseBody над всеми методами.
🔴Всё, что возвращает метод, автоматически превращается в JSON.



2️⃣ Принимаем данные (3 главных способа)

Как вытащить информацию из запроса?

А. Из пути URL (@PathVariable)
Используем, когда параметр - это часть адреса ресурса.

🔴URL: GET /users/42
🔴Код:

@GetMapping("/users/{id}")
public User getById(@PathVariable Long id) { ... }





Б. Из параметров запроса (@RequestParam)
Используем для фильтрации, сортировки или опциональных параметров.

URL: GET /users?role=ADMIN&age=25
Код:

@GetMapping("/users")
public List<User> search(
@RequestParam String role,
@RequestParam(required = false) Integer age // Опционально
) { ... }





В. Из тела запроса (@RequestBody)
Используем для отправки сложных объектов (обычно в POST/PUT запросах). Spring возьмет JSON и сам превратит его в Java-объект (DTO).

JSON: { "name": "Alex", "email": "a@b.com" }
Код:

@PostMapping("/users")
public User create(@RequestBody UserDto userDto) { ... }





3️⃣ Управляем ответом (ResponseEntity)

Просто вернуть объект User, это хорошо (статус будет 200 OK). Но что, если мы хотим вернуть 404 (Not Found) или 201 (Created)?

Для этого используем обертку ResponseEntity<T>.

💻 Пример: Идеальный контроллер


@RestController
@RequestMapping("/api/v1/users") // Общий префикс для всех методов
public class UserController {

private final UserService service; // Внедряем через конструктор

public UserController(UserService service) {
this.service = service;
}

// 1. Получить всех (GET 200 OK)
@GetMapping
public List<User> getAll() {
return service.findAll();
}

// 2. Найти одного (с управлением статусом)
@GetMapping("/{id}")
public ResponseEntity<User> getOne(@PathVariable Long id) {
return service.findById(id)
.map(user -> ResponseEntity.ok(user)) // 200 OK
.orElse(ResponseEntity.notFound().build()); // 404 Not Found
}

// 3. Создать (POST 201 Created)
@PostMapping
public ResponseEntity<User> create(@RequestBody UserDto dto) {
User created = service.save(dto);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
}



Jackson Magic (Pro-Tip)

Иногда вам не нужно отдавать все поля объекта (например, пароль).
Не пишите код для скрытия! Используйте аннотации Jackson прямо в DTO:

@JsonIgnore - поле не попадет в JSON.
@JsonProperty("full_name") - поле fullName в Java станет full_name в JSON.

🔥 Итог

• Используйте @RestController для API.
@PathVariable - для ID (/users/1).
@RequestParam - для фильтров (/users?sort=name).
@RequestBody - для больших данных (JSON).
• Возвращайте ResponseEntity, чтобы контролировать HTTP-статусы.

#SpringBoot #REST #Controller #API #Java


📲 Мы в MAX

👉@BookJava
Please open Telegram to view this post
VIEW IN TELEGRAM
👍41
🔴 Завтра тестовое собеседование с Java-разработчиком

21 января(уже завтра!) в 19:00 по мск приходи онлайн на открытое собеседование, чтобы посмотреть на настоящее интервью на Middle Java-разработчика.

Как это будет:
📂 Сергей Чамкин, старший разработчик из Uzum, ex-WildBerries, будет задавать реальные вопросы и задачи разработчику-добровольцу
📂 Cергей будет комментировать каждый ответ респондента, чтобы дать понять чего от вас ожидает собеседующий на интервью
📂 В конце можно будет задать любой вопрос Сергею

Это бесплатно. Эфир проходит в рамках менторской программы от ШОРТКАТ для Java-разработчиков, которые хотят повысить свой грейд, ЗП и прокачать скиллы.

Переходи в нашего бота, чтобы получить ссылку на эфир →
@shortcut_sh_bot

Реклама.
О рекламодателе.
Please open Telegram to view this post
VIEW IN TELEGRAM
👍4
💾 Spring Data JPA: SQL больше не нужен?

Spring Data JPA это абстракция над Hibernate (который, в свою очередь, является реализацией JPA).
Его главная киллер-фича: Генерация запросов из названий методов.

🏗 1. Сущность (@Entity)

Сначала мы объясняем Java, как выглядит наша таблица. Обычный класс превращается в таблицу с помощью пары аннотаций.


@Entity // Это таблица в БД
@Table(name = "users")
public class User {
@Id // Это Primary Key
@GeneratedValue(strategy = GenerationType.IDENTITY) // Авто-инкремент
private Long id;

private String email;
private int age;
private boolean active;

// Геттеры, сеттеры...
}



🪄 2. Репозиторий (Магия)

Вместо написания класса UserDao, мы просто создаем интерфейс.


public interface UserRepository extends JpaRepository<User, Long> {
// Здесь пусто! Но методы уже есть.
}



Наследуясь от JpaRepository, вы сразу получаете готовые методы:

🔴.save(user) - сохранить/обновить.
🔴.findById(id) - найти по ID (возвращает Optional).
🔴.findAll() - найти всех.
🔴.deleteById(id) - удалить.

Ни одной строчки SQL писать не пришлось! 😎

🔮 3. Derived Queries (Запросы из имени)

Что, если нужно найти пользователя по email? Или всех активных пользователей старше 18 лет?
Вы просто пишете метод в интерфейсе с правильным названием, и Spring сам составляет SQL-запрос.


public interface UserRepository extends JpaRepository<User, Long> {

// SQL: SELECT * FROM users WHERE email = ?
Optional<User> findByEmail(String email);

// SQL: SELECT * FROM users WHERE active = true AND age > ?
List<User> findByActiveTrueAndAgeGreaterThan(int age);

// SQL: EXISTS (SELECT 1 FROM users WHERE email = ?)
boolean existsByEmail(String email);
}



Синтаксис простой: find + By + ИмяПоля + Условие (если нужно).

🛠 4. Если магия не справилась (@Query)

Иногда названия методов становятся слишком длинными и уродливыми (findByNameAndAgeAndActiveAnd...). Или нужен сложный JOIN.
Тогда мы берем управление в свои руки и пишем запрос на JPQL (Java Persistence Query Language) - это SQL, но оперирующий классами, а не таблицами.


@Query("SELECT u FROM User u WHERE u.email LIKE %:domain%")
List<User> findUsersByEmailDomain(@Param("domain") String domain);



Транзакции (@Transactional)

База данных требует транзакций (всё или ничего).
В Spring Boot методы репозитория уже транзакционны (только на чтение).
Если же вы в сервисе делаете несколько операций подряд (снять деньги, перевести деньги), вешайте @Transactional над методом сервиса.


@Service
public class PaymentService {
@Transactional // Если упадет ошибка, все изменения откатятся
public void transferMoney() {
repo.withdraw(...);
repo.deposit(...);
}
}



🔥 Итог

Spring Data JPA убирает 90% рутинной работы с БД.

1. Создали @Entity.
2. Создали интерфейс extends JpaRepository.
3. Нужен поиск? Написали метод findByField.
4. Сложный запрос? Написали @Query.

#SpringBoot #JPA #Hibernate #Database #SQL

📲 Мы в MAX

👉@BookJava
Please open Telegram to view this post
VIEW IN TELEGRAM
👍5👎2