Channel: Эшу быдлокодит
На выходных был на рыбалке на Рыбинском водохранилище, открывал летний водомоторный сезон.
В сумме я провел на воде около 16 часов, кидая спиннинг и исследуя место впадения речки Рени в Рыбинское водохранилище. Ехал я за щукой, но она не клевала, совсем: ни единой отметины зубов на приманках я не увидел. Клевал окунь, два (грамм по 300) оказались достаточно голодными, чтобы проглотить щучью приманку.
Несмотря на малое количество рыбы, поездка удалась: сильно прокачался в управлении лодкой, обкидал новый спиннинг, попал в ливень посреди залива, покупался.
#природа
В сумме я провел на воде около 16 часов, кидая спиннинг и исследуя место впадения речки Рени в Рыбинское водохранилище. Ехал я за щукой, но она не клевала, совсем: ни единой отметины зубов на приманках я не увидел. Клевал окунь, два (грамм по 300) оказались достаточно голодными, чтобы проглотить щучью приманку.
Несмотря на малое количество рыбы, поездка удалась: сильно прокачался в управлении лодкой, обкидал новый спиннинг, попал в ливень посреди залива, покупался.
#природа
Последние полтора года я работал в банке ПСБ, но пришло время расставаться. Был получен крайне интересный опыт, правда больше организационный: я увидел как может быть организована разработка в крупной организации с серьезным контролем качества и сохранности данных.
За полтора года пользования внутренними платформенными решениями и следования корпоративным стандартам кода я неслабо забыл базовые вещи, потому далее будет серия постов-шпаргалок по разным вопросам, которые могут встретиться на собеседованиях на сеньора-шарписта.
Основная цель - осознать и запомнить некоторые моменты, которые не держатся в голове, заодно получив материалы для освежения в памяти базы.
Далее подобные посты будут публиковаться под тегом #собес
За полтора года пользования внутренними платформенными решениями и следования корпоративным стандартам кода я неслабо забыл базовые вещи, потому далее будет серия постов-шпаргалок по разным вопросам, которые могут встретиться на собеседованиях на сеньора-шарписта.
Основная цель - осознать и запомнить некоторые моменты, которые не держатся в голове, заодно получив материалы для освежения в памяти базы.
Далее подобные посты будут публиковаться под тегом #собес
И начну я серию постов к собеседованиям #собес с базовой базы: первых четырёх нормальных форм (НФ) реляционки. Вообще, я как-то больше по денормализации, потому попробую снабдить каждую из НФ короткими ремарками, как я её попирал.
Первая нормальная форма: в таблицах есть первичные ключи, отсутствуют дубли, к тому же нет составных данных, то есть не допускается хранение данных в одной записи в виде массива или просто утрамбованных в текстовый тип данных с разделителем. Про то, как я мучался с дублями в таблице в ~500Гб можно почитать тут. А еще у меня на одном из рабочих проектов был опыт, когда одно из хранимых мной полей могло быть long, long[], guid или objectId (монговский формат "уникального" id). Сохранение в виде строки не проходило по требованиям к объему базы, раскидывание по разным строкам - по требованиям к производительности. В итоге я изобразил свой бинарный формат и хранил тупо байтики.
Поясню: long - id сущности из другой системы, например - "инфаркт" (система была для медтеха). long[] - жесткая сцепка двух понятий, например - "инфаркт в анамнезе", что несет несколько другой смысл для принятия врачебных решений (делалась СППВР).
Вторая нормальная форма. База пребывает в первой форме, а в дополнение - данные во всех столбцах зависят от первичного ключа целиком. Нарушал, сознательно, получилось удачно. Таблица, в которой хранятся логи того, что произошло с заказом. Ключ у таблицы составной, пусть будет id заказа + id изменения, уникальный в рамках данного заказа. Мне понадобилось иметь быстрый доступ к статусу заказа, ну я и стал его проставлять во всех записях, относящихся к заказу. В результате можно получить некоторый выигрыш в получении статуса: указываем id заказа, после чего первая встреченная нами запись гарантировано содержит нужную нам информацию. Вообще, это дикость и варварство, не надо так делать, но в том конкретном случае получилось отлично за счёт некоторых нюансов, про которые я когда-нибудь напишу отдельно.
Третья нормальная форма + нормальная форма Бойса-Кодда - база пребывает в первой и второй НФ, к тому же отсутствует один из любимых (в т.ч. мной) способов посрезать углы: вместо внешнего ключа, указывающего на таблицу-справочник, писать значение из несостоявшегося справочника прямо в основную таблицу. Встречал подобное десятки раз, обычно так сохраняют статусы, меняющиеся со временем или обвешивают записи системой тегов для группировки или фильтрации. И сам так делал и буду делать, если дозволяют принятые стандарты.
Для себя не вижу смысла разделять их между собой. С т.з. формального определения разница существенная, а вот стандартное нарушение едино.
Четвертая нормальная форма - база пребывает во всех предшествующих нормальных формах, а в дополнение в таблицах отсутствуют данные, зависящие только от первичного ключа, но при этом не имеющие между собой логических связей. Этот принцип я нарушал множество раз и с особым цинизмом. Один из примеров - был у меня маленький проект, связанный с системой построения отчётов FastReports (я писал о ней). Мне нужно было решить проблему хранения файлов отчётов и информации о них: название, тег (идентификатор отчёта, по которому его запрашивает фронт), группа, сам файл макета отчёта, сериализованный в base64 (да, хранить такое в базе - дикость, но иногда приходится).
По хорошему, у меня должно было быть три или четыре таблицы, связанные внешними ключами, что-то вроде: reports_info, reports_tags, reports_groups, reports_base64_files. Но я все утрамбовал в одну таблицу, вынимая по тегу отчёт, имеющий самую свежую дату. Примитивно и сердито.
Из кучи просмотренного в интернете по нормальным формам, самой толковой оказалась информация на сайте ИТМО: первая и вторая, третья, четвертая.
Первая нормальная форма: в таблицах есть первичные ключи, отсутствуют дубли, к тому же нет составных данных, то есть не допускается хранение данных в одной записи в виде массива или просто утрамбованных в текстовый тип данных с разделителем. Про то, как я мучался с дублями в таблице в ~500Гб можно почитать тут. А еще у меня на одном из рабочих проектов был опыт, когда одно из хранимых мной полей могло быть long, long[], guid или objectId (монговский формат "уникального" id). Сохранение в виде строки не проходило по требованиям к объему базы, раскидывание по разным строкам - по требованиям к производительности. В итоге я изобразил свой бинарный формат и хранил тупо байтики.
Поясню: long - id сущности из другой системы, например - "инфаркт" (система была для медтеха). long[] - жесткая сцепка двух понятий, например - "инфаркт в анамнезе", что несет несколько другой смысл для принятия врачебных решений (делалась СППВР).
Вторая нормальная форма. База пребывает в первой форме, а в дополнение - данные во всех столбцах зависят от первичного ключа целиком. Нарушал, сознательно, получилось удачно. Таблица, в которой хранятся логи того, что произошло с заказом. Ключ у таблицы составной, пусть будет id заказа + id изменения, уникальный в рамках данного заказа. Мне понадобилось иметь быстрый доступ к статусу заказа, ну я и стал его проставлять во всех записях, относящихся к заказу. В результате можно получить некоторый выигрыш в получении статуса: указываем id заказа, после чего первая встреченная нами запись гарантировано содержит нужную нам информацию. Вообще, это дикость и варварство, не надо так делать, но в том конкретном случае получилось отлично за счёт некоторых нюансов, про которые я когда-нибудь напишу отдельно.
Третья нормальная форма + нормальная форма Бойса-Кодда - база пребывает в первой и второй НФ, к тому же отсутствует один из любимых (в т.ч. мной) способов посрезать углы: вместо внешнего ключа, указывающего на таблицу-справочник, писать значение из несостоявшегося справочника прямо в основную таблицу. Встречал подобное десятки раз, обычно так сохраняют статусы, меняющиеся со временем или обвешивают записи системой тегов для группировки или фильтрации. И сам так делал и буду делать, если дозволяют принятые стандарты.
Для себя не вижу смысла разделять их между собой. С т.з. формального определения разница существенная, а вот стандартное нарушение едино.
Четвертая нормальная форма - база пребывает во всех предшествующих нормальных формах, а в дополнение в таблицах отсутствуют данные, зависящие только от первичного ключа, но при этом не имеющие между собой логических связей. Этот принцип я нарушал множество раз и с особым цинизмом. Один из примеров - был у меня маленький проект, связанный с системой построения отчётов FastReports (я писал о ней). Мне нужно было решить проблему хранения файлов отчётов и информации о них: название, тег (идентификатор отчёта, по которому его запрашивает фронт), группа, сам файл макета отчёта, сериализованный в base64 (да, хранить такое в базе - дикость, но иногда приходится).
По хорошему, у меня должно было быть три или четыре таблицы, связанные внешними ключами, что-то вроде: reports_info, reports_tags, reports_groups, reports_base64_files. Но я все утрамбовал в одну таблицу, вынимая по тегу отчёт, имеющий самую свежую дату. Примитивно и сердито.
Из кучи просмотренного в интернете по нормальным формам, самой толковой оказалась информация на сайте ИТМО: первая и вторая, третья, четвертая.
Telegram
Эшу быдлокодит
Палантир. Часть 14. Дубли в базе. Боль и страдания.
#палантир@eshu_coding
Одной из первых проблем были дубли в данных: одно и то же сообщение засасывалось более одного раза.
В какой-то момент я принял решение просто наплевать на них: ну есть у меня 15%…
#палантир@eshu_coding
Одной из первых проблем были дубли в данных: одно и то же сообщение засасывалось более одного раза.
В какой-то момент я принял решение просто наплевать на них: ну есть у меня 15%…
Небольшая рефлексия полученного ранее опыта.
У меня был в работе нагруженный метод, собирающий и обогащающий информацию с 50+ http вызовов к другим микросервисам, при том часть вызовов шло с использованием данных с предыдущих ответов.
Изначально все вызовы я пустил строго последовательно, рассудив, что отладка параллельных вызовов превратится в ад (так в последствии и оказалось).
Когда большая часть багов была выловлена, я занялся распараллеливанием и оптимизацией. В итоге число вызовов сократилось до ~30 и получился занятный граф вызовов, напоминающий протекание реки через сеть островов. Моей ошибкой было ограничиться кодом, забив на визуализацию. В итоге часть информации запрашивается два раза, к счастью это по факту не на что не повлияло. Вывод на будущее: сначала рисуем схему, потом пишем код, или хотя бы параллельно.
Ещё нерешенным для меня остаётся вопрос, как архитектурно правильно организовать кеширование в сервисе с множеством источников данных. Жёсткого стандарта на эту тему не было, кешировалось все каждым разработчиком на том слое, где это казалось рациональным. В итоге получилось не очень красиво: при перемещении между реализациями методов, инфраструктурный код взаимодействия с кешем оказался замешан с бизнесовым. Я бы попробовал ограничиться жёстким стандартом: кешируются, только финальные аггрегации данных, подготовленные к отдаче запросившему и только данные, получаемые из внешних источников. Кеширование промежуточных агрегаций данных в общем случае я бы запретил.
#архитектура
У меня был в работе нагруженный метод, собирающий и обогащающий информацию с 50+ http вызовов к другим микросервисам, при том часть вызовов шло с использованием данных с предыдущих ответов.
Изначально все вызовы я пустил строго последовательно, рассудив, что отладка параллельных вызовов превратится в ад (так в последствии и оказалось).
Когда большая часть багов была выловлена, я занялся распараллеливанием и оптимизацией. В итоге число вызовов сократилось до ~30 и получился занятный граф вызовов, напоминающий протекание реки через сеть островов. Моей ошибкой было ограничиться кодом, забив на визуализацию. В итоге часть информации запрашивается два раза, к счастью это по факту не на что не повлияло. Вывод на будущее: сначала рисуем схему, потом пишем код, или хотя бы параллельно.
Ещё нерешенным для меня остаётся вопрос, как архитектурно правильно организовать кеширование в сервисе с множеством источников данных. Жёсткого стандарта на эту тему не было, кешировалось все каждым разработчиком на том слое, где это казалось рациональным. В итоге получилось не очень красиво: при перемещении между реализациями методов, инфраструктурный код взаимодействия с кешем оказался замешан с бизнесовым. Я бы попробовал ограничиться жёстким стандартом: кешируются, только финальные аггрегации данных, подготовленные к отдаче запросившему и только данные, получаемые из внешних источников. Кеширование промежуточных агрегаций данных в общем случае я бы запретил.
#архитектура
Распишу один из самых часто задаваемых вопросов на собеседованиях - принципы SOLID.
S: Single Responsibility Principle (Принцип единственной ответственности). Класс должен решать только какую-то одну задачу. Видел в интернетах много высосанных из пальца примеров, попробую дать примеры из жизни. К основному нарушению этого принципа я бы отнес замешивание в одном месте бизнесового и инфраструктурного кода. Имеем класс с методом с какой-то ядреной бизнес логикой, и тут, ВНЕЗАПНО нам нужно получить дополнительные данные. Не выходя из метода открываем подключение к БД, формируем запрос, читаем ответ, продолжаем бизнес логику. Чувствуете запах?:) Или другой вариант - замешиваем в одном классе получение данных из брокера и их бизнесовую обработку.
Ну и классический пример - антипаттерн singleton. Плоха не единственность экземпляра класса, а самоплальная логика обеспечения единственности.
O: Open-Closed Principle (Принцип открытости-закрытости). Классы должны быть открыты для расширения и закрыты для модификации. Самый простой пример, попирающий этот принцип - использование else if бесконечных размеров, куда по мере развития добавляются новые вариации ветвлений со своей логикой. Самое интересное начинается спустя несколько лет, когда приходится спиливать лишние загибы ветвлений или что-то изменять, оставляя часть функционала неизменной. Альтернатива - больше использовать следующий принцип.
L: Liskov Substitution Principle (Принцип подстановки Барбары Лисков). Объекты в программе должны быть заменяемы их наследниками без изменения корректности программы. В целом - самый очевидный принцип, который осознанно нарушают достаточно редко.
I: Interface Segregation Principle (Принцип разделения интерфейса). Классы не должны реализовывать методы только для совместимости с абстракцией предка. Самый простой путь нарушить предыдущий принцип - реализовывать часть методов только для сохранения совместимости. Как правило, это делается спустя рукава или без полного погружения в контекст.
D: Dependency Inversion Principle (Принцип инверсии зависимостей). Классы должны зависеть не от других классов, а от интерфейсов, чтобы в любой момент можно было подменить реализацию. Очевидная на бумаге вещь, но до чего же лениво ей следовать!
#собес
#solid
S: Single Responsibility Principle (Принцип единственной ответственности). Класс должен решать только какую-то одну задачу. Видел в интернетах много высосанных из пальца примеров, попробую дать примеры из жизни. К основному нарушению этого принципа я бы отнес замешивание в одном месте бизнесового и инфраструктурного кода. Имеем класс с методом с какой-то ядреной бизнес логикой, и тут, ВНЕЗАПНО нам нужно получить дополнительные данные. Не выходя из метода открываем подключение к БД, формируем запрос, читаем ответ, продолжаем бизнес логику. Чувствуете запах?:) Или другой вариант - замешиваем в одном классе получение данных из брокера и их бизнесовую обработку.
Ну и классический пример - антипаттерн singleton. Плоха не единственность экземпляра класса, а самоплальная логика обеспечения единственности.
O: Open-Closed Principle (Принцип открытости-закрытости). Классы должны быть открыты для расширения и закрыты для модификации. Самый простой пример, попирающий этот принцип - использование else if бесконечных размеров, куда по мере развития добавляются новые вариации ветвлений со своей логикой. Самое интересное начинается спустя несколько лет, когда приходится спиливать лишние загибы ветвлений или что-то изменять, оставляя часть функционала неизменной. Альтернатива - больше использовать следующий принцип.
L: Liskov Substitution Principle (Принцип подстановки Барбары Лисков). Объекты в программе должны быть заменяемы их наследниками без изменения корректности программы. В целом - самый очевидный принцип, который осознанно нарушают достаточно редко.
I: Interface Segregation Principle (Принцип разделения интерфейса). Классы не должны реализовывать методы только для совместимости с абстракцией предка. Самый простой путь нарушить предыдущий принцип - реализовывать часть методов только для сохранения совместимости. Как правило, это делается спустя рукава или без полного погружения в контекст.
D: Dependency Inversion Principle (Принцип инверсии зависимостей). Классы должны зависеть не от других классов, а от интерфейсов, чтобы в любой момент можно было подменить реализацию. Очевидная на бумаге вещь, но до чего же лениво ей следовать!
#собес
#solid
Эшу быдлокодит
Распишу один из самых часто задаваемых вопросов на собеседованиях - принципы SOLID. S: Single Responsibility Principle (Принцип единственной ответственности). Класс должен решать только какую-то одну задачу. Видел в интернетах много высосанных из пальца…
По горячим следам пример к букве I - разделению интерфейсов.
Например: пишем бота в телеграмме, у нас есть сущность - сообщение. Хочется(если честно - не очень) сделать какой-то интерфейс для типичных методов для работы с ним IMessage с методами GetText, GetAttachedMedia, LogToDB и т.д.
Но у нас есть нюанс: сообщения бывают входящие и исходящие. Исходящие после формирования надо отправлять в телегу, то есть нужен метод Send.
Но добавление этого метода в общий интерфейс заставит нас делать какую-то нерабочую фигню во входящем сообщении, потому интерфейсы надо разделить: ICommonMessage, а от него наследуются два интерфейса: IIncomeMessage и IOutcomeMessage, объявляющие свои специфические методы.
#собес
#solid
Например: пишем бота в телеграмме, у нас есть сущность - сообщение. Хочется
Но у нас есть нюанс: сообщения бывают входящие и исходящие. Исходящие после формирования надо отправлять в телегу, то есть нужен метод Send.
Но добавление этого метода в общий интерфейс заставит нас делать какую-то нерабочую фигню во входящем сообщении, потому интерфейсы надо разделить: ICommonMessage, а от него наследуются два интерфейса: IIncomeMessage и IOutcomeMessage, объявляющие свои специфические методы.
#собес
#solid
Поступила идея ответить на следущий вопрос интервьюера: приведите примеры ситуаций, когда нарушение каждой из букв оправдано.
Одна ситуация общая на все: мы чиним упавший прод, на кону деньги / репутация. А дальше попробую привести примеры из более спокойных ситуаций.
S. Принцип единства ответственности. Самый первый приходящий на ум случай оправданного нарушения его - наша бизнес логика предполагает строгую транзакционность операции.
Начали выполнение - добавили запись в БД. Следущий шаг - ещё запись, в другое место.
Если все шаги выполнены успешно - коммитнули транзакцию.
Разумеется, можно делать по фен-шую, с сохранением каждого шага и его компенсацией в случае неудачи операции в целом. Но это уже сложная логика, требующая поддержки и какого-то централизованного подхода на уровне проекта.
А можно поступить проще: получить контекст ORM-ки на старте операции, по ходу вносить туда изменения. В случае, если дошли до финала - вызвать у контекста SaveChanges. А если что-то завалилось по пути - транзакция будет откачена, как будто ничего и не было.
#собес
#solid
Одна ситуация общая на все: мы чиним упавший прод, на кону деньги / репутация. А дальше попробую привести примеры из более спокойных ситуаций.
S. Принцип единства ответственности. Самый первый приходящий на ум случай оправданного нарушения его - наша бизнес логика предполагает строгую транзакционность операции.
Начали выполнение - добавили запись в БД. Следущий шаг - ещё запись, в другое место.
Если все шаги выполнены успешно - коммитнули транзакцию.
Разумеется, можно делать по фен-шую, с сохранением каждого шага и его компенсацией в случае неудачи операции в целом. Но это уже сложная логика, требующая поддержки и какого-то централизованного подхода на уровне проекта.
А можно поступить проще: получить контекст ORM-ки на старте операции, по ходу вносить туда изменения. В случае, если дошли до финала - вызвать у контекста SaveChanges. А если что-то завалилось по пути - транзакция будет откачена, как будто ничего и не было.
#собес
#solid
А вообще, мы в отпуске. Стартовали вчера в составе жена, я, два ребенка - 6 и 3.5 лет и племянница 14 лет. Мы поехали на машине в Мурманск и окрестности. Первый перегон - Москва - Волхов. Заночевали в посуточно сдающейся квартире. Несмотря на неказистый вид дома позапрошлого века постройки, для квартиры за 2тыс/сутки прям отлично. И детям громить особенно нечего:)
Отдельно порадовал бесконтактный заезд: ключ был хорошо спрятан на территории у дома, уже думал что будет ненаход.
#отпуск
Отдельно порадовал бесконтактный заезд: ключ был хорошо спрятан на территории у дома, уже думал что будет ненаход.
#отпуск
Перейдем к ситуациях, когда допустимо попирать следущую букву.
O. Принцип открытости-закрытости: классы должны быть открыты для расширения, но закрыты для модификации.
Оправдано нарушение этого принципа в случае, когда- наш проект не планируется активно дорабатывать.
Предположим, мы выполняем функции паллиативной поддепдки легаси проекта, который в обозримой перспективе будет отключен. Лучше писать как проще, чем умножать сущности изящными решениями. А чтобы не возникало искушения творческого использования написанных нами заплаток - запрещаем наследование классов, делая их sealed.
Другой вариант - наш проект - платформенное решение. Мы его делаем, тщательно фиксируем работоспособное состояние тестами, после чего распространяем между коллегами как инструмент, облегчающий решение каких-то типовых задач. Приоритет в таком случае произволительность и стабильность, а не академически правильный код. Изменения в проект вносятся крайне редко, во основном - багфиксы. Один такой мой проект уже года 2 крутится без багов и доработок.
#собес
#solid
O. Принцип открытости-закрытости: классы должны быть открыты для расширения, но закрыты для модификации.
Оправдано нарушение этого принципа в случае, когда- наш проект не планируется активно дорабатывать.
Предположим, мы выполняем функции паллиативной поддепдки легаси проекта, который в обозримой перспективе будет отключен. Лучше писать как проще, чем умножать сущности изящными решениями. А чтобы не возникало искушения творческого использования написанных нами заплаток - запрещаем наследование классов, делая их sealed.
Другой вариант - наш проект - платформенное решение. Мы его делаем, тщательно фиксируем работоспособное состояние тестами, после чего распространяем между коллегами как инструмент, облегчающий решение каких-то типовых задач. Приоритет в таком случае произволительность и стабильность, а не академически правильный код. Изменения в проект вносятся крайне редко, во основном - багфиксы. Один такой мой проект уже года 2 крутится без багов и доработок.
#собес
#solid
Перейду к описанию кейсов, когда нарушение принципа, соответствующего следующей букве в аббревиатуре #solid оправдано.
L. Принцип подстановки Барбары Лисков: объекты в программе должны быть заменяемы их наследниками без изменения корректности программы.
Первый кейс напрашивается сам собой: ломать совместимость нужнотогда, когда нужно, а когда не нужно - не стоит, если на то есть технические или бизнесовые основания.
У нас есть некоторая апишка, часть методов которой приговорена к удалению. Для походов в нее используется внутренний пакет, скрывающий от подключивших его всю инфраструктурную кухню.
Хорошим тоном перед реальным удалением методов будет выпустить пакет с новой реализацией обращений к апишке, который вместо выполнения работы будет кидать исключения.
Этот пакет может быть использован как инструмент для подготовки к полному удалению методов: легко искать места, которые нужно доработать, при этом, в случае проблем, всегда можно быстро откатиться на прошлую версию и взять паузу.
Второй кейс: поломка корректности работы программы в целом нам не критична. Например - мы собираем какой-то стенд, имитирующий работы части нашей системы для тестирования (нагрузочного, регрессионного или ещё какого-то - не важно). В данном случае нам нужна корректная работа только той части методов, тестирование которой предусмотрено тест планом.
#собес
L. Принцип подстановки Барбары Лисков: объекты в программе должны быть заменяемы их наследниками без изменения корректности программы.
Первый кейс напрашивается сам собой: ломать совместимость нужно
У нас есть некоторая апишка, часть методов которой приговорена к удалению. Для походов в нее используется внутренний пакет, скрывающий от подключивших его всю инфраструктурную кухню.
Хорошим тоном перед реальным удалением методов будет выпустить пакет с новой реализацией обращений к апишке, который вместо выполнения работы будет кидать исключения.
Этот пакет может быть использован как инструмент для подготовки к полному удалению методов: легко искать места, которые нужно доработать, при этом, в случае проблем, всегда можно быстро откатиться на прошлую версию и взять паузу.
Второй кейс: поломка корректности работы программы в целом нам не критична. Например - мы собираем какой-то стенд, имитирующий работы части нашей системы для тестирования (нагрузочного, регрессионного или ещё какого-то - не важно). В данном случае нам нужна корректная работа только той части методов, тестирование которой предусмотрено тест планом.
#собес
HTML Embed Code: