Чи дійсний шаблон відвідувача в цьому сценарії?


9

Мета мого завдання - розробити невелику систему, яка може виконувати заплановані повторювані завдання. Повторне завдання - це на кшталт "надсилати електронному листу адміністратору щогодини з 8:00 до 17:00, з понеділка по п’ятницю".

У мене базовий клас під назвою RecurringTask .

public abstract class RecurringTask{

    // I've already figured out this part
    public bool isOccuring(DateTime dateTime){
        // implementation
    }

    // run the task
    public abstract void Run(){

    }
}

І у мене є кілька класів, які успадковані від RecurringTask . Один з них називається SendEmailTask .

public class SendEmailTask : RecurringTask{
    private Email email;

    public SendEmailTask(Email email){
        this.email = email;
    }

    public override void Run(){
        // need to send out email
    }
}

І у мене є EmailService, який може допомогти мені надіслати електронний лист.

Останній клас - RecurringTaskScheduler , він відповідає за завантаження завдань з кешу чи бази даних та виконання завдання.

public class RecurringTaskScheduler{

    public void RunTasks(){
        // Every minute, load all tasks from cache or database
        foreach(RecuringTask task : tasks){
            if(task.isOccuring(Datetime.UtcNow)){
                task.run();
            }
        }
    }
}

Ось моя проблема: куди мені поставити EmailService ?

Option1 : Inject EmailService в SendEmailTask

public class SendEmailTask : RecurringTask{
    private Email email;

    public EmailService EmailService{ get; set;}

    public SendEmailTask (Email email, EmailService emailService){
        this.email = email;
        this.EmailService = emailService;
    }

    public override void Run(){
        this.EmailService.send(this.email);
    }
}

Вже є деякі дискусії щодо того, чи варто вводити послугу в організацію, і більшість людей погоджуються, що це не є хорошою практикою. Дивіться цю статтю .

Варіант2: Якщо ... інше в RecurringTaskScheduler

public class RecurringTaskScheduler{
    public EmailService EmailService{get;set;}

    public class RecurringTaskScheduler(EmailService emailService){
        this.EmailService = emailService;
    }

    public void RunTasks(){
        // load all tasks from cache or database
        foreach(RecuringTask task : tasks){
            if(task.isOccuring(Datetime.UtcNow)){
                if(task is SendEmailTask){
                    EmailService.send(task.email); // also need to make email public in SendEmailTask
                }
            }
        }
    }
}

Мені сказали, якщо ... Інакше і так, як це було вище, це не ОО, і це принесе більше проблем.

Варіант3: Змініть підпис Run і створіть ServiceBundle .

public class ServiceBundle{
    public EmailService EmailService{get;set}
    public CleanDiskService CleanDiskService{get;set;}
    // and other services for other recurring tasks

}

Введіть цей клас у RecurringTaskScheduler

public class RecurringTaskScheduler{
    public ServiceBundle ServiceBundle{get;set;}

    public class RecurringTaskScheduler(ServiceBundle serviceBundle){
        this.ServiceBundle = ServiceBundle;
    }

    public void RunTasks(){
        // load all tasks from cache or database
        foreach(RecuringTask task : tasks){
            if(task.isOccuring(Datetime.UtcNow)){
                task.run(serviceBundle);
            }
        }
    }
}

Run метод SendEmailTask буде

public void Run(ServiceBundle serviceBundle){
    serviceBundle.EmailService.send(this.email);
}

Я не бачу великих проблем з таким підходом.

Варіант4 : шаблон відвідувачів.
Основна ідея - створити відвідувача, який буде інкапсулювати послуги так само, як ServiceBundle .

public class RunTaskVisitor : RecurringTaskVisitor{
    public EmailService EmailService{get;set;}
    public CleanDiskService CleanDiskService{get;set;}

    public void Visit(SendEmailTask task){
        EmailService.send(task.email);
    }

    public void Visit(ClearDiskTask task){
        //
    }
}

І нам також потрібно змінити підпис методу Run . Run метод SendEmailTask є

public void Run(RecurringTaskVisitor visitor){
    visitor.visit(this);
}

Це типова реалізація Шаблона відвідувачів, і відвідувач буде введений у RecurringTaskScheduler .

Підсумовуючи: Серед цих чотирьох підходів, який із моїх сценаріїв найкращий? І чи є велика різниця між Варіантом 3 та Варіантом 4 для цієї проблеми?

Або ви краще розумієте цю проблему? Дякую!

Оновлення 22.05.2015 : Я думаю, що відповідь Енді дуже добре підсумовує мій намір; якщо ви все ще плутаєтесь із самою проблемою, пропоную спочатку прочитати його допис.

Щойно я з’ясував, що моя проблема дуже схожа на проблему відправки повідомлень , що призводить до варіанту5.

Варіант 5 : Перетворіть мою проблему на розсилку повідомлень .
Між моєю проблемою та проблемою диспетчеризації повідомлень відображається індивідуальне відображення :

Диспетчер повідомлень : Одержуйте повідомлення та диспетчери підкласів частування відповідних обробників. → RecurringTaskScheduler

Посилання : інтерфейс або абстрактний клас. → Повторне завдання

ПовідомленняA : поширюється на функцію IMessage , має додаткову інформацію. → SendEmailTask

MessageB : Ще один підклас IMessage . → CleanDiskTask

MessageAHandler : Коли ви отримаєте MessageA , обробіть його → SendEmailTaskHandler, який містить EmailService, і надішле електронний лист, коли отримає SendEmailTask

MessageBHandler : Те саме, що і MessageAHandler , але обробляйте MessageB . → CleanDiskTaskHandler

Найскладніша частина полягає в тому, як розсилати різного роду повідомлення для різних обробників. Ось корисне посилання .

Мені дуже подобається такий підхід, він не забруднює мою сутність службою і не має класу Бога .


Ви не позначили мову чи платформу, але рекомендую заглянути в cron . На вашій платформі може бути бібліотека, яка працює аналогічно (наприклад, jcron, який здається дефектом ). Планування завдань і завдань є значною мірою вирішеною проблемою: ви розглядали інші варіанти перед тим, як розгорнути свій власний? Чи були причини не використовувати їх?

@Snowman Ми можемо пізніше перейти до зрілої бібліотеки. Все залежить від мого менеджера. Тому я розміщую це запитання - я хочу знайти спосіб вирішити цю проблему. Я не раз бачив подібну проблему, і не зміг знайти елегантне рішення. Тож мені цікаво, чи зробив я щось не так.
Sher10ck

Досить справедливо, я завжди намагаюся рекомендувати повторно використовувати код, якщо можливо, хоча.

1
SendEmailTaskмені здається більше схожим на послугу, ніж на сутність. Я б пішов на варіант 1 без вагань.
Барт ван Іґен Шенау

3
Чого не вистачає (для мене) для відвідувача - це структура класу, яка acceptвідвідувачів. Мотивація для відвідувача полягає в тому, що у вас є багато типів класу в якомусь сукупності, які потребують відвідування, і не зручно змінювати їх код для кожного нового функціоналу (операції). Я все ще не бачу, що це об'єднані об'єкти, і вважаю, що відвідувач не підходить. Якщо це так, слід відредагувати своє запитання (яке стосується відвідувача).
Фурманатор

Відповіді:


4

Я б сказав, що варіант 1 - найкращий шлях. Причина, яку ви не повинні звільняти, полягає в тому, що SendEmailTaskце не суб'єкт господарювання. Суб'єкт господарювання - об'єкт, що займається зберіганням даних та станом. У вашому класі дуже мало цього. Насправді це не сутність, але вона містить сутність: Emailоб’єкт, який ви зберігаєте. Це означає, що Emailне слід брати послугу чи мати #Sendметод. Натомість у вас повинні бути сервіси, які приймають такі організації, як ваша EmailService. Таким чином, ви вже дотримуєтесь ідеї відмовитись від послуг юридичних осіб.

Оскільки SendEmailTaskце не сутність, тому цілком чудово вводити в нього електронну пошту та службу, і це потрібно робити через конструктор. Роблячи впорскування конструктора, ми можемо бути впевнені, що SendEmailTaskзавжди готові виконати свою роботу.

Тепер давайте розберемося, чому б не зробити інші варіанти (конкретно стосовно SOLID ).

Варіант 2

Вам справедливо сказали, що розгалуження на подібний тип принесе більше головних болів у дорозі. Давайте розберемося, чому. По-перше, ifсхильні кластеризуватися та зростати. Сьогодні це завдання надсилати електронні листи, а завтра кожен клас класів потребує різного обслуговування чи іншої поведінки. Управління цією ifзаявою стає кошмаром. Оскільки ми розгалужуємо тип (і в даному випадку явний тип ), ми підриваємо систему типів, вбудовану в нашу мову.

Варіант 2 не є єдиною відповідальністю (SRP), тому що раніше багаторазове використання RecurringTaskSchedulerповинно знати про всі ці різні види завдань, а також про всі різні види послуг та поведінки, які вони можуть знадобитися. Цей клас набагато важче повторно використовувати. Він також не є відкритим / закритим (OCP). Оскільки йому потрібно знати про таку задачу чи ту чи іншу (або подібну послугу чи ту), різні зміни в завданнях або послугах можуть призвести до змін тут. Додати нове завдання? Додати нову послугу? Змінити спосіб обробки електронної пошти? Зміна RecurringTaskScheduler. Оскільки тип завдання має значення, воно не дотримується Лісковської заміни (LSP). Він не може просто отримати завдання і зробити його. Він повинен запитати тип і на основі типу робити це чи робити це. Замість того, щоб укладати відмінності в завдання, ми все це втягуємо в RecurringTaskScheduler.

Варіант 3

Варіант 3 має деякі великі проблеми. Навіть у статті, на яку ви посилаєтесь , автор перешкоджає цьому:

  • Ви все ще можете використовувати статичний локатор служби ...
  • Я уникаю локатора обслуговування, коли можу, особливо коли локатор служби повинен бути статичним ...

Ви створюєте сервіс-локатор зі своїм ServiceBundleкласом. У цьому випадку він не виглядає статичним, але все ще має багато проблем, властивих локатору сервісу. Ваші залежності тепер приховані під цим ServiceBundle. Якщо я дам вам наступний API мого нового класного завдання:

class MyCoolNewTask implements RecurringTask
{
    public bool isOccuring(DateTime dateTime) {
        return true; // It's always happenin' here!
    }

    public void Run(ServiceBundle bundle) {
        // yeah, some awesome stuff here
    }
}

Які послуги я використовую? Які послуги потрібно знущатися з тесту? Що заважає мені використовувати будь-яку послугу в системі, просто тому?

Якщо я хочу використовувати вашу систему завдань для виконання деяких завдань, я зараз залежу від кожної служби у вашій системі, навіть якщо я використовую лише кілька або навіть жодну взагалі.

ServiceBundleНе дуже SRP , тому що потрібно знати про кожної службі у вашій системі. Це також не OCP. Додавання нових сервісів означає зміни до ServiceBundle, а зміни в ServiceBundleможе означати неоднакові зміни в завданнях в інших місцях. ServiceBundleне відокремлює свій інтерфейс (ISP). Він має розширений інтерфейс усіх цих послуг, і оскільки це лише постачальник цих послуг, ми можемо розглянути його інтерфейс, щоб охопити також інтерфейси всіх послуг, які він надає. Завдання більше не дотримуються інверсії залежності (DIP), оскільки їх залежності затухають позаду ServiceBundle. Це також не дотримується Принципу найменших знань (він же Закон Деметера), оскільки речі знають про набагато більше речей, ніж повинні.

Варіант 4

Раніше у вас було безліч дрібних предметів, які могли працювати самостійно. Варіант 4 включає всі ці об'єкти і розбиває їх в один Visitorоб'єкт. Цей об’єкт виступає як об'єкт бога над усіма вашими завданнями. Це зводить ваші RecurringTaskоб’єкти до анемічних тіней, які просто викликають відвідувача. Вся поведінка рухається до Visitor. Потрібно змінити поведінку? Потрібно додати нове завдання? Зміна Visitor.

Більш складна частина полягає в тому, що всі різні форми поведінки в одному класі, зміна деяких поліморфно тягне за собою всю іншу поведінку. Наприклад, ми хочемо мати два різних способи надсилання електронної пошти (можливо, вони повинні використовувати різні сервери?). Як би ми це зробили? Ми могли б створити IVisitorінтерфейс і реалізувати це, потенційно дублюючи код, як #Visit(ClearDiskTask)у нашого початкового відвідувача. Тоді, якщо ми придумаємо новий спосіб очищення диска, нам доведеться знову реалізувати та дублювати. Тоді ми хочемо обох видів змін. Знову реалізуйте та копіюйте. Ці дві різні, розрізнені форми поведінки нерозривно пов'язані.

Може, замість цього ми могли просто підкласи Visitor? Підклас з новою поведінкою електронної пошти, підклас з новою поведінкою на диску. Немає дублювання поки! Підклас з обома? Тепер те чи інше потрібно дублювати (або обидва, якщо це ваше вподобання).

Порівняємо з варіантом 1: Нам потрібна нова поведінка електронної пошти. Ми можемо створити нове, RecurringTaskщо робить нову поведінку, вводити її в залежність і додати її до колекції завдань у RecurringTaskScheduler. Нам навіть не потрібно говорити про очищення дисків, тому що ця відповідальність лежить десь в іншому місці. У нас також є повний набір інструментів ОО. Наприклад, ми могли б прикрасити це завдання записом журналу.

Варіант 1 доставить вам найменший біль, і це найправильніший спосіб вирішити цю ситуацію.


Ваш аналіз на Otion2,3,4 є фантастичним! Мені це справді дуже допомагає. Але для Option1 я б стверджував, що * SendEmailTask ​​* - це сутність. У нього є ідентифікатор, його повторюваний зразок та інша корисна інформація, яка повинна зберігатися в db. Я думаю, Енді добре підсумує мій намір. Можливо, ім’я типу * EMailTaskDefinitions * є більш підходящим. я не хочу забруднювати свою особу своїм кодом обслуговування. Ейфорія згадує певну проблему, якщо я ввожу службу в сутність. Я також оновлюю своє запитання і включаю Option5, який, на мою думку, є найкращим рішенням поки що.
Sher10ck

@ Sher10ck Якщо ви витягуєте конфігурацію для своєї SendEmailTaskбази даних, то ця конфігурація має бути окремим класом конфігурації, який також слід вводити у вашу SendEmailTask. Якщо ви генеруєте дані зі свого SendEmailTask, ви повинні створити об’єкт пам’яті для зберігання стану та помістити їх у свою базу даних.
cbojar

Мені потрібно витягнути конфігурацію з db, так що ви пропонуєте вводити як EMailTaskDefinitionsі EmailServiceв SendEmailTask? Потім RecurringTaskSchedulerмені потрібно ввести щось на зразок SendEmailTaskRepository, відповідальність за який - завантаження визначення та обслуговування та введення їх у SendEmailTask. Але я б заперечував, що зараз RecurringTaskSchedulerпотрібно знати сховище кожного завдання, наприклад CleanDiskTaskRepository. І мені потрібно міняти RecurringTaskSchedulerкожен раз, коли у мене з’являється нове завдання (додати сховище до планувальника).
Шер10к

@ Sher10ck RecurringTaskSchedulerПотрібно усвідомлювати лише концепцію узагальненого сховища завдань та a RecurringTask. Це може залежати від абстракцій. Репозиторії завдань можуть бути введені в конструктор RecurringTaskScheduler. Тоді про різні сховища потрібно знати лише те, де RecurringTaskSchedulerвстановлено інстанцію (або їх можна заховати на фабриці та викликати звідти). Оскільки це залежить лише від абстракцій, RecurringTaskSchedulerне потрібно мінятися з кожним новим завданням. У цьому суть інверсії залежності.
cbojar

3

Ви переглядали існуючі бібліотеки, наприклад, весняний кварц або весняний пакет (я не впевнений, що найбільше відповідає вашим потребам)?

До вашого питання:

Я припускаю, що проблема полягає в тому, що ви хочете зберегти деякі метадані до завдання поліморфно, тому завдання електронної пошти має призначені адреси електронної пошти, завдання-журнал на рівні журналу тощо. Ви можете зберігати список цих пам'яті у пам'яті або у вашій базі даних, але для відокремлення проблем, які ви не хочете забруднити суб'єктом господарювання кодом послуги.

Моє запропоноване рішення:

Я б розділив виконувану та частину даних завдання, щоб мати, наприклад, TaskDefinitiona TaskRunner. TaskDefinition має посилання на TaskRunner або фабрику, яка створює його (наприклад, якщо потрібна якась установка, наприклад smtp-хост). Фабрика є специфічною - вона може обробляти лише EMailTaskDefinitions і повертає лише екземпляри EMailTaskRunners. Таким чином, це більше OO і безпечне зміна - якщо ви вводите новий тип завдання, вам доведеться ввести новий конкретний завод (або повторно використовувати його), якщо ви не можете його не скласти.

Таким чином, ви отримаєте залежність: рівень сутності -> сервісний рівень і знову, тому що Бігун потребує інформації, що зберігається в об'єкті, і, ймовірно, хоче оновити його стан у БД.

Ви могли б розірвати це порочне коло, використовуючи загальний завод, який бере на TaskDefinition і повертає специфічний TaskRunner, але це потребує багато МФСА. Ви можете використовувати роздуми, щоб знайти бігун, який так само названий як ваше визначення, але будьте обережні, такий підхід може коштувати певної продуктивності та може призвести до помилок виконання.

PS Я припускаю, що тут Java. Я думаю, що це схоже в .net. Основна проблема тут - подвійне зв’язування.

До шаблону відвідувачів

Я думаю, це було скоріше призначене для обміну алгоритмом для різних видів об'єктів даних під час виконання, ніж для цілей подвійного прив'язки. Наприклад, якщо у вас є різні види страхування та різні види їх розрахунку, наприклад, тому, що цього вимагають різні країни. Потім ви вибираєте конкретний метод розрахунку і застосовуєте його за кількома страховками.

У вашому випадку ви вибрали б конкретну стратегію завдань (наприклад, електронну пошту) та застосували її до всіх своїх завдань, що неправильно, оскільки не всі вони є завданнями електронної пошти.

PS Я його не перевіряв, але думаю, що ваш варіант 4 також не буде працювати, оскільки він знову подвійний зв'язок.


Ви дуже добре підсумуєте мій намір, THX! Я хотів би розірвати коло. Тому що, якщо дозволити TaskDefiniton містити посилання на TaskRunner або завод, це така ж проблема, як і Option1. Я розглядаю фабрику або TaskRunner як службу. Якщо потреби TaskDefinition містять посилання на них, ви або вводите службу в TaskDefinition , або використовуєте якийсь статичний метод, якого я намагаюся уникати.
Sher10ck

1

Я повністю не згоден з цією статтею. Послуги (конкретно їх "API") є важливою стороною Ділового домену і як такі існуватимуть у Доменній моделі. І немає жодних проблем з суб'єктами господарської діяльності, посилаючись на щось інше в тій же області бізнесу.

Коли X надсилає пошту Y.

Це правило бізнесу. А для цього потрібна служба, яка надсилає пошту. І суб'єкт, який обробляє, When Xповинен знати про цю послугу.

Але є деякі проблеми з реалізацією. Користувач суб'єкта господарювання повинен бути прозорим, щоб суб'єкт господарювання використовував послугу. Тож додавати послугу в конструктор - це не дуже добре. Це також питання, коли ви десериалізуєте сутність із бази даних, оскільки вам потрібно встановити як дані об'єкта, так і екземпляри служб. Найкращим рішенням, про який я можу придумати, є використання ін'єкції властивостей після створення об'єкта. Можливо, змушує кожен новостворений екземпляр будь-якої сутності пройти метод "ініціалізації", який вводить усі сутності, яким потрібна сутність.


На яку статтю ви посилаєтесь, з якою ви не згодні? Однак цікава точка зору на доменну модель. Напевно, ви можете бачити це таким чином, хоча люди зазвичай уникають змішування служб у сутності, оскільки це створить тісну зв'язок дуже скоро.
Енді

@Andy Той, на який посилався Шер10к, у своєму запитанні. І я не бачу, як це створило б тісну зв’язку. Будь-який погано написаний код може ввести щільне з'єднання.
Ейфорія

1

Це велике питання та цікава проблема. Я пропоную використовувати комбінацію моделей ланцюга відповідальності та подвійної диспетчеризації (приклади шаблонів тут ).

Спочатку давайте визначимо ієрархію завдань. Зауважте, що зараз існує декілька runметодів здійснення подвійної відправки.

public abstract class RecurringTask {

    public abstract boolean isOccuring(Date date);

    public boolean run(EmailService emailService) {
        return false;
    }

    public boolean run(ExecuteService executeService) {
        return false;
    }
}

public class SendEmailTask extends RecurringTask {

    private String email;

    public SendEmailTask(String email) {
        this.email = email;
    }

    @Override
    public boolean isOccuring(Date date) {
        return true;
    }

    @Override
    public boolean run(EmailService emailService) {
        emailService.runTask(this);
        return true;
    }

    public String getEmail() {
        return email;
    }
}

public class ExecuteTask extends RecurringTask {

    private String program;

    public ExecuteTask(String program) {
        this.program = program;
    }

    @Override
    public boolean isOccuring(Date date) {
        return true;
    }

    public String getName() {
        return program;
    }

    @Override
    public boolean run(ExecuteService executeService) {
        executeService.runTask(this);
        return true;
    }
}

Далі давайте визначимо Serviceієрархію. Ми будемо використовувати Services для формування ланцюжка відповідальності.

public abstract class Service {

    private Service next;

    public Service(Service next) {
        this.next = next;
    }

    public void handleRecurringTask(RecurringTask req) {
        if (next != null) {
            next.handleRecurringTask(req);
        }
    }
}

public class ExecuteService extends Service {

    public ExecuteService(Service next) {
        super(next);
    }

    void runTask(ExecuteTask task) {
        System.out.println(String.format("%s running %s with content '%s'", this.getClass().getSimpleName(),
                task.getClass().getSimpleName(), task.getName()));
    }

    public void handleRecurringTask(RecurringTask req) {
        if (!req.run(this)) {
            super.handleRecurringTask(req);
        }
    }
}

public class EmailService extends Service {

    public EmailService(Service next) {
        super(next);
    }

    public void runTask(SendEmailTask task) {
        System.out.println(String.format("%s running %s with content '%s'", this.getClass().getSimpleName(),
                task.getClass().getSimpleName(), task.getEmail()));
    }

    public void handleRecurringTask(RecurringTask req) {
        if (!req.run(this)) {
            super.handleRecurringTask(req);
        }
    }
}

Заключний твір - це те, RecurringTaskSchedulerщо оркеструє процес завантаження та запуску.

public class RecurringTaskScheduler{

    private List<RecurringTask> tasks = new ArrayList<>();

    private Service chain;

    public RecurringTaskScheduler() {
        chain = new EmailService(new ExecuteService(null));
    }

    public void loadTasks() {
        tasks.add(new SendEmailTask("here comes the first email"));
        tasks.add(new SendEmailTask("here is the second email"));
        tasks.add(new ExecuteTask("/root/python"));
        tasks.add(new ExecuteTask("/bin/cat"));
        tasks.add(new SendEmailTask("here is the third email"));
        tasks.add(new ExecuteTask("/bin/grep"));
    }

    public void runTasks(){
        for (RecurringTask task : tasks) {
            if (task.isOccuring(new Date())) {
                chain.handleRecurringTask(task);
            }
        }
    }
}

Тепер ось приклад програми, що демонструє систему.

public class App {

    public static void main(String[] args) {
        RecurringTaskScheduler scheduler = new RecurringTaskScheduler();
        scheduler.loadTasks();
        scheduler.runTasks();
    }
}

Запуск виходів програми:

EmailService працює SendEmailTask з вмістом «тут приходить перша електронна пошта»
EmailService працює SendEmailTask з вмістом «тут є другим по електронній пошті»
ExecuteService працює ExecuteTask з вмістом «/ корінь / пітон»
ExecuteService працює ExecuteTask з вмістом «/ бен / кішка»
EmailService працює SendEmailTask з вміст "ось третій електронний лист"
ExecuteService запускає ExecuteTask із вмістом "/ bin / grep"


У мене може бути багато завдань . Кожен раз, коли я додаю нове завдання , мені потрібно змінити RecurringTask, а також мені потрібно змінити всі його підкласи, тому що мені потрібно додати нову функцію, як публічний абстрактний булевий запуск (OtherService otherService) . Я думаю, що у Варіанта 4 така модель відвідувачів, яка також реалізує подвійну розсилку, має ту саму проблему.
Sher10ck

Гарна думка. Я відредагував свою відповідь, щоб методи запуску (служби) були визначені в RecurringTask і повернули помилково за замовчуванням. Таким чином, коли вам потрібно додати ще один клас завдань, вам не потрібно доторкатися до завдань побратимів.
iluwatar
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.