Wednesday, May 28, 2014

Введение в Prism 5. CompositeCommand. Часть вторая

Эта статья является продолжением статьи "CompositeCommand in Prism. Part 1". В этой части мы рассмотрим, как работают подписчики через EventAggreagetor, а также добавим возможность удалять заказы с помощью составных команд. Для региона OrderRegion я немного поигрался со стилями для кнопок "Cancel" и "Cancel All". Перейдем в папку Assets и добавим новый словарь ресурсов Style.xaml.
Реализация стилей приведена ниже.
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
                    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <ControlTemplate x:Key="CancelButtonTemplate" TargetType="Button">
        <Grid Height="{TemplateBinding Height}">
            <Border Width="105" BorderBrush="#FFFFFFFF" BorderThickness="1,2,2,2" CornerRadius="9,9,9,9" Background="#FFFFFFFF">
                <TextBlock Text="{TemplateBinding Content}" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="#FF666666"  Margin="-9,0,0,0" />
            </Border>
        </Grid>
    </ControlTemplate>

    <ControlTemplate x:Key="SubmitButtonTemplate" TargetType="Button">
        <Grid Height="{TemplateBinding Height}">
            <Border Width="105" x:Name="BgEnabled" Background="#FF006C3B" BorderBrush="#FFFFFFFF" BorderThickness="2,2,1,2" CornerRadius="9,9,9,9" HorizontalAlignment="Right">
                <TextBlock x:Name="textBlock" Text="{TemplateBinding Content}" HorizontalAlignment="Center" VerticalAlignment="Center" Foreground="#FFFFFFFF" />
            </Border>
        </Grid>
        <ControlTemplate.Triggers>

            <Trigger Property="IsEnabled" Value="false">
                <Trigger.EnterActions>
                    <BeginStoryboard>
                        <Storyboard>
                            <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" Duration="00:00:00.3" Storyboard.TargetName="textBlock" Storyboard.TargetProperty="(UIElement.Opacity)"  AutoReverse="true">
                                <SplineDoubleKeyFrame KeyTime="00:00:00.3" Value="0" KeySpline="0.5,0,0.5,1" />
                            </DoubleAnimationUsingKeyFrames>
                            <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" Duration="00:00:00.3" Storyboard.TargetName="BgEnabled" Storyboard.TargetProperty="(FrameworkElement.Width)"  AutoReverse="true">
                                <SplineDoubleKeyFrame KeyTime="00:00:00.3" Value="0" KeySpline="0.5,0,0.5,1" />
                            </DoubleAnimationUsingKeyFrames>
                            <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" Duration="00:00:00.3" Storyboard.TargetName="BgEnabled" Storyboard.TargetProperty="(UIElement.Opacity)"  AutoReverse="true">
                                <SplineDoubleKeyFrame KeyTime="00:00:00.3" Value="0" KeySpline="0.5,0,0.5,1" />
                            </DoubleAnimationUsingKeyFrames>
                            <ColorAnimationUsingKeyFrames BeginTime="00:00:00" Duration="00:00:00.2" Storyboard.TargetName="BgEnabled" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)">
                                <SplineColorKeyFrame KeyTime="00:00:00.1" Value="#FF006C3B"/>
                                <SplineColorKeyFrame KeyTime="00:00:00.2" Value="#FF6C6C6C"/>
                            </ColorAnimationUsingKeyFrames>
                        </Storyboard>
                    </BeginStoryboard>
                </Trigger.EnterActions>
                <Trigger.ExitActions>
                    <BeginStoryboard>
                        <Storyboard>
                            <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" Duration="00:00:00.3" Storyboard.TargetName="textBlock" Storyboard.TargetProperty="(UIElement.Opacity)"  AutoReverse="true">
                                <SplineDoubleKeyFrame KeyTime="00:00:00.3" Value="0" KeySpline="0.5,0,0.5,1" />
                            </DoubleAnimationUsingKeyFrames>
                            <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" Duration="00:00:00.3" Storyboard.TargetName="BgEnabled" Storyboard.TargetProperty="(FrameworkElement.Width)"  AutoReverse="true">
                                <SplineDoubleKeyFrame KeyTime="00:00:00.3" Value="0" KeySpline="0.5,0,0.5,1" />
                            </DoubleAnimationUsingKeyFrames>
                            <DoubleAnimationUsingKeyFrames BeginTime="00:00:00" Duration="00:00:00.3" Storyboard.TargetName="BgEnabled" Storyboard.TargetProperty="(UIElement.Opacity)"  AutoReverse="true">
                                <SplineDoubleKeyFrame KeyTime="00:00:00.3" Value="0" KeySpline="0.5,0,0.5,1" />
                            </DoubleAnimationUsingKeyFrames>
                            <ColorAnimationUsingKeyFrames BeginTime="00:00:00" Duration="00:00:00.2" Storyboard.TargetName="BgEnabled" Storyboard.TargetProperty="(Border.Background).(SolidColorBrush.Color)">
                                <SplineColorKeyFrame KeyTime="00:00:00.1" Value="#FF6C6C6C"/>
                                <SplineColorKeyFrame KeyTime="00:00:00.2" Value="#FF006C3B"/>
                            </ColorAnimationUsingKeyFrames>
                        </Storyboard>
                    </BeginStoryboard>
                </Trigger.ExitActions>
            </Trigger>

        </ControlTemplate.Triggers>
    </ControlTemplate>

</ResourceDictionary>
Стиль взят тот, который идет в поставке вместе с Prism 5 в приложении StockTraderRI_Desktop, я только немного его допилил, поэтому на авторство не претендую. Для того чтобы особо не заморачиваться с подключением ресурсов и стилей, сделаем их глобальными, объявив их в App.xaml.
<Application x:Class="EventAggregatorSample.App"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Application.Resources>
        <ResourceDictionary>
            <ResourceDictionary.MergedDictionaries>
                <ResourceDictionary Source="Assets\Style.xaml"/>
            </ResourceDictionary.MergedDictionaries>
        </ResourceDictionary>
    </Application.Resources>
</Application>
Поскольку использовать хоть какие-то обработчики событий в модели является плохим стилем (лучше никогда не использовать такой подход, потому что это нарушает паттерн MVVM), в наш слой Presenters, который описан в предыдущей статье, добавим класс BookPresenterViewModel. Реализация этого класса несложная, и, по сути,это просто обертка над моделью Book.
public class BookPresenterViewModel
{
    #region Private Variables
    private readonly Book _book;
    #endregion
    public BookPresenterViewModel(Book book)
    {
        _book = book;
        RemoveBookCommand = new DelegateCommand(Remove);
    }

    public long Id
    {
        get { return _book.Id; }
        set
        {
            _book.Id = value;
        }
    }

    public double Price
    {
        get { return _book.Price; }
        set
        {
            _book.Price = value;
        }
    }

    public string Author
    {
        get { return _book.Author; }
        set
        {
            _book.Author = value;
        }
    }
    public event EventHandler<DataEventArgs<BookPresenterViewModel>> Removed;

    public ICommand RemoveBookCommand { get; private set; }

    private void Remove()
    {
        // Notify that the order was removed.
        OnRemoved(new DataEventArgs<BookPresenterViewModel>(this));
    }

    private void OnRemoved(DataEventArgs<BookPresenterViewModel> e)
    {
        EventHandler<DataEventArgs<BookPresenterViewModel>> removeHandler = Removed;
        if (removeHandler != null)
        {
            removeHandler(this, e);
        }
    }
}
В данный класс мы добавили команду RemoveBookCommand, а также событие Removed, которое вызывается в том случае, если мы вызвали команду удаления для нашего класса. По сути, мы реализовали паттерн Адаптер для нашего примера. Событие Removed необходимо, чтобы уведомить родительский контрол о том, что мы хотим удалить данный элемент. Также я решил написать некую реализацию паттерна адаптер для модели Order. Эту реализацию назвал OrderPresenterViewModel и прописал в ней обработку удаления книги, чтобы в самой модели представления оставить по минимуму логики.
public class OrderPresenterViewModel : BindableBase
{
    #region Private Variables
    private readonly Order _order;
    #endregion

    public OrderPresenterViewModel(Order order)
    {
        _order = order;
        RemoveAllBooksCommand = new CompositeCommand();
    }

    public Customer Customer
    {
        get
        {
            return _order.Customer;
        }
        set
        {
            _order.Customer = value;
            OnPropertyChanged(() => Customer);
        }
    }

    public double Sum
    {
        get
        {
            return _order.Sum;
        }
        set
        {
            _order.Sum = value;
            OnPropertyChanged(() => Sum);
        }
    }

    public CompositeCommand RemoveAllBooksCommand { get; set; }

    public ObservableCollection<BookPresenterViewModel> Books { get; set; }
    public void AddBook(Book newBook)
    {
        if (Books == null)
            Books = new ObservableCollection<BookPresenterViewModel>();

        var bookPresenter = new BookPresenterViewModel(newBook);

        bookPresenter.Id = Books.Count + 1;
        bookPresenter.Removed += newBook_Removed;
        RemoveAllBooksCommand.RegisterCommand(bookPresenter.RemoveBookCommand);
        Books.Add(bookPresenter);
        Sum = Books.Sum(x => x.Price);
    }

    void newBook_Removed(object sender, DataEventArgs<BookPresenterViewModel> e)
    {
        if (e != null && e.Value != null)
        {
            BookPresenterViewModel book = e.Value;
            if (Books.Contains(book))
            {
                book.Removed -= newBook_Removed;
                RemoveAllBooksCommand.UnregisterCommand(book.RemoveBookCommand);
                Books.Remove(book);
                Sum = Books.Sum(x => x.Price);
            }

            if (!Books.Any())
                Remove(); //Remove Order
        }
    }

    public event EventHandler<DataEventArgs<OrderPresenterViewModel>> Removed;

    private void Remove()
    {
        // Notify that the order was removed.
        OnRemoved(new DataEventArgs<OrderPresenterViewModel>(this));
    }

    private void OnRemoved(DataEventArgs<OrderPresenterViewModel> e)
    {
        EventHandler<DataEventArgs<OrderPresenterViewModel>> removeHandler = Removed;
        if (removeHandler != null)
        {
            removeHandler(this, e);
        }
    }
}
В классе OrderPresenterViewModel мы добавили обработку события на добавление новой книги. Эту роль на себя взяла функция AddBook. Ниже приведу эту функцию и детально постараюсь по ней пройтись, чтобы у вас не возникало никаких вопросов.
public void AddBook(Book newBook)
{
    if (Books == null)
        Books = new ObservableCollection<BookPresenterViewModel>();

    var bookPresenter = new BookPresenterViewModel(newBook);

    bookPresenter.Id = Books.Count + 1;
    bookPresenter.Removed += newBook_Removed;
    RemoveAllBooksCommand.RegisterCommand(bookPresenter.RemoveBookCommand);
    Books.Add(bookPresenter);
    Sum = Books.Sum(x => x.Price);
}
В этой функции не хватает некоторых проверок, как, например, проверка входного аргумента функции на null, немного корявая логика с проверкой на пустую коллекцию книг. Это все вы можете легко поправить, так как эти проблемы видны сразу и легко поправимы. Для новой книги мы создаем обертку BookPresenterViewModel, которой конструктор передает книгу, которую мы добавляем. Затем мы подписываемся на событие Removed, которое уведомит нас о том, что пользователь нажал на кнопку "Cancel", и нам нужно удалить данную книги из коллекции. Следующим шагом мы регистрируем команду в CompositeCommand, которым выступает свойство RemoveAllBooksCommand. По поводу такого использования класса CompositeCommand хотелось бы сказать несколько слов. Если вы знакомы с данным классом, то наверняка видели примеры подобные этому, взятому с документации (пример Commanding_Desktop).
/// <summary>
/// Defines the SaveAll command. This command is defined as a static so
/// that it can be easily accessed throughout the application.
/// </summary>
public static class OrdersCommands
{
    //TODO: 03 - The application defines a global SaveAll command which invokes the Save command on all registered Orders. It is enabled only when all orders can be saved.
    public static CompositeCommand SaveAllOrdersCommand = new CompositeCommand();
}

/// <summary>
/// Provides a class wrapper around the static SaveAll command.
/// </summary>
public class OrdersCommandProxy
{
    public virtual CompositeCommand SaveAllOrdersCommand
    {
        get { return OrdersCommands.SaveAllOrdersCommand; }
    }
}
Подобный пример вы сможете увидеть также в приложении StockTraderRI_Desktop, которое идет в поставке. Как видите из примера, свойства и классы для CompositeCommand объявляют с использованием ключевого слова static. Этот подход удобен, если нужно отслеживать сквозные изменения, а не изменения в рамках одной модели. Для того чтобы отслеживать изменения в рамках активной модели, можно воспользоваться и статическим объявлением нашей составной команды, но для этого нам в конструктор нужно будет передать параметр true, как показано ниже.
public static CompositeCommand SaveAllOrdersCommand = new CompositeCommand(true);
Можно также реализовать поддержку интерфейса IActiveAware. Это добавляет лишней логики, которая нам не нужна. Поэтому для примера я создавал композитную команду на каждый заказ.
Самый сложный момент уже описан выше. Теперь осталось реализовать совсем немного простой логики, и лишь в одном месте с использованием паттерна EventAggregator потребуется чуть более детальный обзор. Для реализации модели представления для заказа переходим в папку ViewModels и добавляем новый класс OrderViewModel.
public class OrderViewModel : BindableBase
{
    private readonly IEventAggregator _eventAggregator;
    private SubscriptionToken subscriptionToken;
    public OrderViewModel(IEventAggregator eventAggregator)
    {
        _eventAggregator = eventAggregator;
        Orders = new ObservableCollection<OrderPresenterViewModel>();
        ShopOrderEvent fundAddedEvent = eventAggregator.GetEvent<ShopOrderEvent>();
        subscriptionToken = fundAddedEvent.Subscribe(AddNewOrderEventHandler, ThreadOption.UIThread, false, (shopOrder) => true);
    }

    public void AddNewOrderEventHandler(ShopOrder shopOrder)
    {
        var order = Orders.FirstOrDefault(x => x.Customer.Id == shopOrder.Customer.Id);
        if(order != null)
        {
            order.AddBook(shopOrder.Book);
        }
        else
        {
            var newOrder = new Order
                {
                    Customer = shopOrder.Customer
                };
            var orderPresenter = new OrderPresenterViewModel(newOrder);
            orderPresenter.Removed += OrderRemoved;
            orderPresenter.AddBook(shopOrder.Book);

            Orders.Add(orderPresenter);
        }
    }

    public ObservableCollection<OrderPresenterViewModel> Orders { get; set; }

    private OrderPresenterViewModel _selectedOrder;
    private OrderPresenterViewModel SelectedOrder
    {
        get { return _selectedOrder; }
        set
        {
            _selectedOrder = value;
            OnPropertyChanged(() => SelectedOrder);
        }
    }

    void OrderRemoved(object sender, DataEventArgs<OrderPresenterViewModel> e)
    {
        if (e != null && e.Value != null)
        {
            OrderPresenterViewModel order = e.Value;
            if (Orders.Contains(order))
            {
                order.Removed -= OrderRemoved;
                Orders.Remove(order);
            }
        }
    }
}
Один из самых сложных моментов в этой модели представления заключается, наверное, в использовании EventAggregator. Эта модель представления выступает у нас подписчиком. Для того чтобы подписаться на какое-то событие с помощью EventAggregator, нам нужно обратиться за помощью к классу SubscriptionToken. Пройдемся внимательно по подписке с помощью класса EventAggreagtor.
ShopOrderEvent fundAddedEvent = eventAggregator.GetEvent<ShopOrderEvent>();
subscriptionToken = fundAddedEvent.Subscribe(AddNewOrderEventHandler, ThreadOption.UIThread, false, (shopOrder) => true);
В данном примере всего две строчки кода, поэтому разберем их детально. Первая строка кода позволяет получить объект события. EventAggregator создаёт объект события при первом запросе, если он ещё не был создан. Он освобождает издателя или подписчика от необходимости определять, доступно ли событие. А основная работа ложится на класс PubSubEvent. В нашем случае это класс ShopOrderEvent. Существует несколько вариантов подписки на данное событие. В первой части статьи я уже приводил эти перегруженные методы, для того чтобы освежить их в памяти, посмотрим еще раз:
public SubscriptionToken Subscribe(Action<TPayload> action);
public SubscriptionToken Subscribe(Action<TPayload> action, bool keepSubscriberReferenceAlive);
public SubscriptionToken Subscribe(Action<TPayload> action, ThreadOptionthreadOption);
public SubscriptionToken Subscribe(Action<TPayload> action, ThreadOptionthreadOption, bool keepSubscriberReferenceAlive);
public virtual SubscriptionToken Subscribe(Action<TPayload> action,ThreadOption threadOption, bool keepSubscriberReferenceAlive, Predicate<TPayload> filter);
Рассмотрим, как работают эти способы подписки.
  • Если вам нужно обновлять элементы управления в пользовательском интерфейсе при получении события, подписывайтесь на получение события в потоке UI.
  • Если вам нужно фильтровать события, предоставьте при подписке делегат для фильтрации.
  • Если для вас критична производительность, рассмотрите использование сильных ссылок при подписке и последующее ручную отписку.
  • Если ни один из пунктов выше вам не подходит, используйте стандартную подписку.
О режиме, в котором будет работать наш подписчик, указывает второй параметр: перечисление ThreadOption. Это перечисление содержит следующие члены:
  • PublisherThread. События будут получены в потоке издателя. Это является стандартным поведением.
  • BackgroundThread. Событие будет получено асинхронно в потоке из пула потоков .NET Framework.
  • UIThread. Событие будет получено в потоке UI.
Если нам нужно фильтровать данные, которые нам поступают, например, некоторые виды заказов нас не интересуют, нам необходимо установить предикат:
Predicate<TPayload> filter
Для нашего примера я сделал это последним параметром через лямбда-выражение.
(shopOrder) => true
Это означает, что меня удовлетворяют все заказы. Напоследок остался последний параметр, который мы не рассмотрели: keepSubscriberReferenceAlive типа bool.
  • При установке в true экземпляр события хранит сильную ссылку на экземпляр подписчика, не позволяя ему тем самым стать достижимым для сборщика мусора. Про отписку от события смотрите раздел далее в статье.
  • При установке в false (что является значением по умолчанию, если не задать этот параметр), экземпляр события создаёт слабую ссылку на подписчика, позволяя ему стать достижимым для сборщика мусора при отсутствии на него других сильных ссылок. При сборке мусора происходит автоматическая отписка от этого события.
После того как мы реализовали нашу модель представления для заказов, нужно реализовать контрол, который будет отображать всю модель в нашем регионе. Для этого перейдем в папку Regions и добавим контрол OrderView.xaml. Ниже приведена реализация данного контрола.
<UserControl x:Class="EventAggregatorSample.Views.Regions.OrderView"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
             xmlns:inf="clr-namespace:EventAggregatorSample.Helpers"
             mc:Ignorable="d"
             Height="Auto" Width="Auto">
    <Grid>
        <ScrollViewer VerticalAlignment="Top" HorizontalAlignment="Stretch" VerticalScrollBarVisibility="Auto" VerticalContentAlignment="Top" BorderThickness="0,0,0,0" Grid.RowSpan="1" Grid.Row="0">
            <ListBox ItemsSource="{Binding Orders}" Width="Auto" HorizontalContentAlignment="Stretch">
                <ListBox.ItemTemplate>
                    <DataTemplate>
                        <Border Margin="10,5,10,10" Grid.Row="1" CornerRadius="4,4,4,4" BorderBrush="#193441" Background="#FFC8DDC5" BorderThickness="2,2,2,2">
                            <Grid>
                                <Grid.RowDefinitions>
                                    <RowDefinition Height="Auto" />
                                    <RowDefinition Height="Auto" />
                                    <RowDefinition Height="Auto" />
                                    <RowDefinition Height="Auto" />
                                </Grid.RowDefinitions>
                                <Border Grid.Row="0">
                                    <StackPanel Orientation="Horizontal">
                                        <TextBlock Text="{Binding Customer.FirstName}" Width="50"/>
                                        <TextBlock Text="{Binding Customer.LastName}" />
                                    </StackPanel>
                                </Border>
                                <ListBox Grid.Row="1" ItemsSource="{Binding Books}">
                                    <ListBox.ItemTemplate>
                                        <DataTemplate>
                                            <StackPanel Orientation="Horizontal">
                                                <TextBlock Text="{Binding Author}" Width="100"/>
                                                <TextBlock Text="{Binding Price, StringFormat={}{0:C}}" FontWeight="Bold" />
                                                <Button Width="120" Template="{StaticResource SubmitButtonTemplate}" Content="Cancel" Margin="5,0,0,0"
                                                        Command="{Binding RemoveBookCommand}"/>
                                            </StackPanel>
                                        </DataTemplate>
                                    </ListBox.ItemTemplate>
                                </ListBox>
                                <Border Grid.Row="2">
                                    <StackPanel Orientation="Horizontal">
                                        <TextBlock Text="Sum:" Width="50"/>
                                        <TextBlock Text="{Binding Sum}" />
                                    </StackPanel>
                                </Border>
                                <StackPanel Height="Auto" VerticalAlignment="Bottom" Orientation="Horizontal" Grid.RowSpan="1" HorizontalAlignment="Center" Grid.Row="3" Margin="5">
                                    <Button Name="CancelAllButton" Template="{StaticResource CancelButtonTemplate}" Height="25"
                                            Command="{Binding RemoveAllBooksCommand}">Cancel All</Button>
                                </StackPanel>
                            </Grid>
                        </Border>
                    </DataTemplate>
                </ListBox.ItemTemplate>
            </ListBox>
        </ScrollViewer>
    </Grid>
</UserControl>
По сути, выше приведен шаблон ListBox.ItemTemplate, который отображает все то, что мы с вами "начудили" раньше. Теперь добавим модуль OrderModule в папку Modules, который привяжет нашу модель представления к нужному представлению, указав при этом, в какой регион наше представление должно быть подставлено.
public class OrderModule : IModule
{
    public OrderModule(IUnityContainer container, IRegionManager regionManager)
    {
        Container = container;
        RegionManager = regionManager;
    }
    public void Initialize()
    {
        var viewModel = Container.Resolve<OrderViewModel>();
        var view = Container.Resolve<OrderView>();
        view.DataContext = viewModel;
        RegionManager.Regions["OrderRegion"].Add(view);
    }

    public IUnityContainer Container { get; private set; }
    public IRegionManager RegionManager { get; private set; }
}
И финальный штрих для всего этого: добавить инициализацию этого модуля в наш загрузчик Bootstrapper.
protected override void InitializeModules()
{
    IModule shopModule = Container.Resolve<ShopModule>();
    IModule orderModule = Container.Resolve<OrderModule>();

    shopModule.Initialize();
    orderModule.Initialize();
}
На всякий случай, приведу полную реализацию загрузчика.
public class Bootstrapper : UnityBootstrapper
{
       protected override DependencyObject CreateShell()
       {
             return Container.Resolve<Shell>();
       }

       protected override void InitializeShell()
       {
             Application.Current.MainWindow = (Window)Shell;
             Application.Current.MainWindow.Show();
       }

    protected override void InitializeModules()
    {
        IModule shopModule = Container.Resolve<ShopModule>();
        IModule orderModule = Container.Resolve<OrderModule>();

        shopModule.Initialize();
        orderModule.Initialize();
    }
}
После проделанной работы мы можем запустить наше приложение и посмотреть на результат.
Результат довольно-таки симпатично выглядит, как мне кажется, и при этом полностью функционирует. Исходный пример получился сложным в некоторых местах. Все время борюсь с самим собой в плане того, как стоит преподносить примеры. Либо оставить самое необходимое, чтобы показать, как нужно использовать, как, например, в примерах с документации, часть из которых содержит много багов и предназначена исключительно для ознакомления. Либо создавать логику такой, которая используется в продуктах которые уходят в поставку на внутренний или внешний рынок.

No comments:

Post a Comment