Andrey on .NET | Реализация CQRS в .NET. Часть 1 – шаблоны CQS и CQRS

Реализация CQRS в .NET. Часть 1 – шаблоны CQS и CQRS

Давайте разберемся что кроется за аббревиатурами CQS и CQRS.

Немного теории

Шаблон Command-Query Separation (CQS) предлагает разделение работы с объектом (и это не обязательно база данных) на Запросы (Query) и Команды (Commands). При этом необходимо соблюдать следующие правила:

  • Запросы возвращает данные и, что важно, никогда не меняют состояние объекта;
  • Команды изменяют состояние объекта, но, в идеальном случае, не должны ничего возвращать.

Из этого следует, что во время отсутствия Команд:

  • одинаковые Запросы гарантировано вернут одинаковый результат;
  • любое количество любых Запросов не изменят состояние объекта;
  • удаление Запроса из кода абсолютно прозрачно для объекта и не может дать побочных действий.

Эти свойства CQS используются в программировании по контракту. В нем методы, осуществляющие проверку состояния объекта, являются Запросами. Это гарантирует их прозрачность для остального кода и возможность безопасного удаления части из них в Release версии кода (например, для оптимизации).

Кроме того, подход, предлагаемый CQS, позволяет сделать код приложения более понятным именно благодаря разделению Команд и Запросов. Соответственно, в дальнейшем такое приложение легче поддерживать и модифицировать.

Необходимо отметить, что в некоторых случаях CQS не может быть использован в принципе. В качестве примеров можно рассмотреть реализации алгоритмов стек и очередь. Их методы Pop() и Dequeue() по определению должны и возвращать данные и изменять состояние самого объекта. Кроме того, в случае многопоточного программирования иногда удобнее иметь методы, противоречащие принципам CQS.

Область использования CQS достаточно широка. Например, он подходит для взаимодействия с элементами управления пользовательского интерфейса. Однако, в случае применения данного принципа для работы с источниками данных используется термин Command-Query Responsibility Segregation (CQRS). По сути это синоним, уточняющий область применения. CQRS несет в себе все принципы CQS и не ограничивает способ хранения данных. Это может быть СУБД, массив в памяти и т. д.

Пример

На небольшом примере посмотрим как может выглядеть реализация CQRS. Допустим, необходимо хранить список ссылок:

public class Link
{
    public int Id { get; set; }
    public string Url { get; set; }
}

Реализуем Запрос, возвращающий ссылку по ее Id. В конструкторе передадим источник данных, а метод Execute() будет возвращать нужный объект.

public class GetUrlByIdQuery
{
    private readonly IEnumerable<Link> _dataSource;

    public GetUrlByIdQuery(IEnumerable<Link> dataSource)
    {
        this._dataSource = dataSource;
    }

    public Link Execute(int id)
    {
        return this._dataSource.FirstOrDefault(e => e.Id == id);
    }
}

Обратите внимание, что знания о источнике данных в классе запроса сведены к минимуму. Он сможет работать как, например, с обычным массивом, так и с MS SQL через Entity Framework.

Аналогично поступим и при реализации Команды (для упрощения она не будет потокобезопасностной).

public class AddLinkCommand
{
    private readonly ICollection<Link> _dataSource;

    public AddLinkCommand(ICollection<Link> dataSource)
    {
        this._dataSource = dataSource;
    }

    public void Execute(string url)
    {
        int maxId = this._dataSource.Max(e => e.Id) + 1;
        this._dataSource.Add(new Link() {
            Id = maxId,
            Url = url
        });
    }
}

Здесь стоит обратить внимание на следующие моменты:

  • в метод Execute() передается строка. Таким образом в Команду передаются только необходимые данные (а не объект целиком). Кроме того, вызывающий метод теперь не обязан знать о классе Link. Все это увеличивает гибкость использования Команд.
  • Команда содержит часть логики приложения (вычисляет новый Id). Ограничений в шаблоне нет.
  • В данном примере для упрощения Команда сама вносит изменения в источник данных. Однако, здесь может быть применен шаблон проектирования Команда (когда Команда только хранит данные для её выполнения, а сам изменения выполняются сторонним обработчиком).

Прейдём к примеру использования:

class Program
{
    static void Main(string[] args)
    {
        var dataSource = new List<Link>() {
            new Link() {Id = 1, Url = "http://msdn.microsoft.com/"}
        };

        Console.WriteLine("*   Начальный список ссылок: ");
        foreach (var urlData in dataSource)
            Console.WriteLine("{0}. {1}", urlData.Id, urlData.Url);

        var query = new GetUrlByIdQuery(dataSource);
        var link = query.Execute(1);
        Console.WriteLine("\n*   Результат запроса:\n{0}. {1}", link.Id, link.Url);

        var cmd = new AddLinkCommand(dataSource);
        cmd.Execute("http://www.windowsazure.com/");

        Console.WriteLine("\n*   Список ссылок после выполнения команды: ");
        foreach (var urlData in dataSource)
            Console.WriteLine("{0}. {1}", urlData.Id, urlData.Url);

        Console.ReadKey(true);
    }
}

Используя CQRS в реальном приложении, можно добиться того, что бизнес-логика не будет ничего знать про тип источника данных. Она будет оперировать только Запросами и Командами. При правильном подходе, это может существенно упростить создание и дальнейшее сопровождение приложения.

В завершении этой части необходимо сказать, что CQS/CQRS не ограничиваются способы их реализации. Это могут быть методы одного объекта или отдельные классы, как в примере выше. Возможно использовать совместно интерфейсы, абстракции, IoC и прочие техники и шаблоны.

В следующей части рассмотрим работу с библиотекой Highway, реализующий шаблон CQRS для .NET.

Комментарии (35) -

Андрей 25.10.2013 15:19:46

Мне вот идейно CQRS нравится, но если пойти дальше Hello World примеров, то не совсем понятно как реализовываются следующие сценарии из реальной жизни.

1) Нужно сохранить какой то объект, в процессе сохранения нужно выполнить несколько проверочных запросов, при успешном сохранении вернуть идентификатор + дополнительная информация, при проблемах у нас может быть список ошибок и его тоже нужно вернуть для сохранения в UI.
По логике получается, что мы должны для сохранения вызвать query т.к. ждем ответа, внутри которого будет куча других query для валидации плюс финальный query для сохранения и возврата результата. Получается полная фигня т.к. команды не возращаются результат, а запросы не меняют состояние. Или это уже будет тот случай когда CQRS не подходит?

2) Нужно произвести выборку данных по каким то критериями, но после выборки нужно залогировать факт выборки для аудита.
Я так понимаю, что здесь используем query для выборки внутри которого вызывается command для аудита?

1) Здесь хорошо подходит шаблон Команда, который я упомянул. Т.е. сама Команда хранит только данные для выполнения. Есть обработчик команд, который содержит логику её выполнения. Я не вижу запрета со стороны CQRS на то, чтобы он читал данные и выполнял проверки (содержал логику сохранения данных). Ошибки вылетают в виде Exception. Лишние Query не нужны.

Вот насчет получения Id после insert - серьезный вопрос. Я специально написал что Команды не возвращают данные в идеале. В практике - приходится или мириться с лишним запросом или же разрешить обработчику обновить поле исходной Команды (например Id). И с точки зрения производительности я пойду скорее по второму варианту (особенно если речь про СУБД).

Если есть идеи - буду рад обсудить.

2) Да, тут уже вступает в игру слой BL, несущий логику самого процесса. Он делает Запрос, убеждается что результат корректный и вызывает Команду для сохранения в журнал аудита. Подчеркну, что Команда (или её обработчик) содержит только логику, необходимую для сохранения данных. А вот уже описанная вами логика - "этажом выше", более комплексная, и управляет вызовами Запросов и отправкой Команд. Собственно это уже вопрос проектирования приложения.

Андрей, я не совсем понял вот это ограничение насчет команды: "в метод Execute() передается строка. Таким образом в Команду передаются только необходимые данные (а не объект целиком)."

Ведь мы как правило на практике работаем с какой-нибудь конкретной ОРМ - EF или NHibernate. Если рассмотреть EF и code first, то у нас будут некоторые классы предметной области. Типичный сценарий - добавление нового пользователя, а в этом случае гораздо удобнее передавать в команду объект User целиком, например:


public class AddNewUserCommand
{
  private readonly DbContext dbContext;
  
  public AddNewUserCommand(DbContext dbContext)
  {
    this.dbContext = dbContext;
  }
  
  public void Execute(User user)
  {
    dbContext.Set<User>().Add(user);
  }
}


(здесь я не вызываю dbContext.SaveChanges(), чтобы команда сама могла быть частью другой транзакции)

@ Deplexor: А это не ограничение, а всего лишь деталь реализации. Ваш вариант вполне соответствует CQRS (но противоречит шаблону проектирования Команда, но это уже отдельный разговор). Можете передавать объект, если так удобнее. Подчеркну 2 момента:

1) Не всегда объект их бизнес-логики хранится 1 в 1. Например, User в БД, может иметь пароль (да еще и не строку, а хэш её и "соли"). Я покажу это на примере в следующей части.

2) Представьте что в User есть 50 свойств, а вам надо обновить Email зная его Login. Что лучше - передать в команду 2 параметра или явно создавать целый объект каждый раз? Так же, в первом случае - email и login могут быть просто строками, т.к. вызывающая сторона вообще про User ничего знать не должна (меньше связанность).

@ Deplexor: Для начала надо научиться/заставить себя разделять команды. Например, когда вы работаете с реляционный БД, вы можете написать один метод Update(IUser user) и обновить через него любые поля пользователя.
С CQRS  вместо одного метода Update лучше создать несколько команд: CreateUser, LockUser, UnlockUser, DeleteUser.
Плюсы такого подхода:
- методы очень простые
- вся логика изменения состояния перед глазами
- наша команда может порождать другую команду, и реализация новой логики никак не усложнит код

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

Andrey:
Вот насчет получения Id после insert - серьезный вопрос. Я специально написал что Команды не возвращают данные в идеале. В практике - приходится или мириться с лишним запросом или же разрешить обработчику обновить поле исходной Команды (например Id). И с точки зрения производительности я пойду скорее по второму варианту (особенно если речь про СУБД).
И тем самым от CQRS у вас ничего не останется.
По поводу уникальных ИД, самое простое использовать GUID, в противном случае надо делать дополнительный запрос.

Еще раз: самое трудное для меня при переходе на CQRS было изменение мышления. Ведь всегда хочется писать по меньше кода и чтоб он работал быстро с первого раза. При этом не особо задумываешься о сопровождение этого кода. Отсюда кстати еще один вывод: если вам надо просто написать приложение, которая не будет развиваться или не будете сопровождать, то лучше это делать по старинке.
А если же проект большой, сложная логика и развивается годами, то есть смысл посмотреть в сторону CQRS.
В своем проекте я работаю с CQRS в связки с EventStore. При этом у меня есть понятие команда и события. Команда говорит, что надо бы это сделать, но это не значит что это сделано. Событие же говорит, что это уже сделано.Например есть команда "создать пользователя", которая может быть вызвана во время регистрации. но по разным причинам эта команда может быть не выполнена. А вот если команда выполнена, то она порождает событие. Все события хранятся в хранилище. Хранилище - это некий список. получается что только события влияют на состояние объекта.
Вот если рассмотреть регистрацию пользователя с точки зрения архитектуры моего приложения, то я бы сделал так:
1.когда пользователь нажимает кнопку "регистрация", создаю команду CreateUser
2.внутри команды проверяю логику и если есть ошибки, выбрасываю исключение
3.в том месте, где создавалась команда отлавливаю ошибку и показываю пользователю сообщение
4.если ошибок нет, то команда создает событие UserCreated
5.теперь самое главное, получение данных. для того чтоб получить данные нашего пользователя, нам нужно знать его ИД. А как его получить, если пользователь еще не создан. а очень просто, надо написать некий генератор ИД. Получается, когда мы создаем команду CreateUser, мы уже знаем ИД этого пользователя, который еще не создан. Этот же ИД будет и в событиях. А теперь зная ИД, можем либо в цикле проверять хранилище событий на то, появилось ли там наше событие, либо сделать так, чтоб хранилище сообщало какие события к нему поступили и подписаться на него.
и еще один момент, т.к. в хранилище у нас только события типа UserCreated,UserLocked,а нам нужно получить как правило последние состояние пользователя, надо написать класс UserState, который умеет правильно обрабатывать все события, касающегося именно пользователя. Например, на событие UserLocked мы должны поменять поле IsLocked=true для указанного пользователя.
Про EventStore лучше почитать https://github.com/NEventStore/NEventStore/ и http://geteventstore.com/ (этот использую я) Как реализован CQRS можете посмотреть здесь https://github.com/Lokad/lokad-cqrs
По нему я как раз обучался и на то чтоб изменить свое мышление у меня ушло порядка 2 месяцев

CQRS - это всё же не просто новое название для CQS. CQRS предполагает, что ваша доменная модель делится на модель для чтения и модель для записи, где модель для чтения вполне может быть денормализована и лежать в отдельной базе данных.

Вот как-то так: blogs.msdn.com/.../5224.image_5F00_054D5D98.png

@ mefcorvi: И за счет этого как раз достигается высокая скорость. Опять же это справедливо в случае, когда в основном мы считываем,а не записываем. Но т.к. в большинстве случаев это так и происходит, то можно утверждать, что это работает всегда.
PS. Если не изменяет память, мы с тобой знакомы ))

@ AigizK:
И тем самым от CQRS у вас ничего не останется.

Сильно резкое утверждение.

Во-первых, выпадающий Exception так же можно расценивать как результат Команды. Не давить же их все? Кстати, вы сами же противоречите себе чуть ниже, когда пишите "внутри команды проверяю логику и если есть ошибки, выбрасываю исключение".

Во-вторых, не заметил прямого запрета на возврат значений Командами. Есть только принципиалный запрет на изменение состояния Запросом.

Ну и в-третьих, разделение при этом никуда не уходит (в большинстве своем Команды это update, которые "молчаливы"). Мы же не говорим о ситуации, когда UpdateUserDetails возращает нового юзера.

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

Вот тут сразу вопросы
1) Как GUID тут поможет? Конечно вероятность ничтожна, но нет 100% гарантии уникальности созданного GUID.
2) Как поможет дополнительный Запрос, есть сами данные сами по себе не уникальны?

Про EventStore лучше почитать

А можете пояснить такой момент: вся идея с событиями окупается только в случае если нам надо иметь историю измеений данных (с событиями легко получить состояние любого поля в любой момент времени) или же вы нашли плюсы и в случае с обычными проектами (где важно только текущее состояние). Я несколько ознакомившись с данным подходом не увидел плюсов, а в минусе - увеличение объема БД, необходимость прочитать все события (и перевыполнить их) начиная от некой отправной точки.

@ mefcorvi:
CQRS предполагает, что ваша доменная модель делится на модель для чтения и модель для записи

Мне все же кажется что мы об одном и том же. Можно сказать, что Команды несут свою модель для записи по определению. У Запросов так же свои модели.  Поэтому использование CQS в приложении автоматически приведет к выполнению данного условия.

Andrey:
Вот тут сразу вопросы
1) Как GUID тут поможет? Конечно вероятность ничтожна, но нет 100% гарантии уникальности созданного GUID.
2) Как поможет дополнительный Запрос, есть сами данные сами по себе не уникальны?

1.предположил что она всегда уникальна
2.с командой отправляешь некий идентификатор и потом мониторишь этот идентификатор. но как написал позже, лучше ИД генерить сразу.
Andrey:
или же вы нашли плюсы и в случае с обычными проектами
Вряд ли для таких проектов нужно использовать события. Хотя я у себя вокруг основного плагина, где как раз нужен EventStore, построил некий CMS и он полностью работает с событиями. Тут пока не попробуешь сам, сложно увидит плюсы и минусы. Например, из за того что я могу вернуться в любое состояние проекта, я разгрузил основной процесс и теперь второстепенные задачи могу выполнить когда нагрузка не такая большая. еще реализовал кнопку, которая отменяет последние действия. при этом реализация очень простая и универсальная.
Ну и расширение кругозора, например в проекте с интернет магазином используя код получения денормализованных данных из основного проекта, ускорил и упростил поиск.

2.с командой отправляешь некий идентификатор и потом мониторишь этот идентификатор. но как написал позже, лучше ИД генерить сразу.

Вот такой вариант мне не нравится. Получается что в угоду шаблону мы правим или доменную модель или DTO из DAL. Хотя возврат в виде Exception все равно остается.

еще реализовал кнопку, которая отменяет последние действия. при этом реализация очень простая и универсальная.

Вот для таких задач эта модель ложится идеально. Правильно ли я понимаю, что по сути вы держите 2 БД - событийную и слепок последнего состояния? Иначе как обеспечить скорость чтения "текущих значений".

Andrey:
вы держите 2 БД
Да

@Andrey @AigizK @mefcorvi

Ок, например, я придерживаюсь в проектировании принципа Onion Architecture jeffreypalermo.com/.../

Доменная модель составляет здесь некий "доменный язык" приложения, его ядро, и ни от чего не зависит (в конечном итоге это же набор plain objects). То есть домен может содержать сложную логику, также может быть написан намного раньше GUI, слоя сохранения и каких-либо других сервисов.

Паттерн Repository (http://martinfowler.com/eaaCatalog/repository.html) скрывает persistent-слой в такой архитектуре и полностью укладывается в нее.

Тогда вот такой вопрос в принципе ко всем участникам дисскурсии: используя CQRS вместо Repository мы по прежнему будем следовать Onion Architecture, либо CQRS уже выходит за ее рамки. И если да, то в чем именно?

P.S. Понимаю, что вопрос немного абстрактно звучит, но все же.

@ Deplexor: Разверну вопрос - где вы видите несоответствие Onion и CQRS? Как будет время допишу статью про свой вариант реализации. Он хорошо соответствует Onion на мой взгляд.

@Andrey

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

AigizK изложил свое видение CQRS в связке событиями, но меня смутили несколько моментов в его описании:

- генерация ID только как GUID самим приложением, а не БД.
-  механизм EventStore требует создание второй базы

Мне представляется, что событийный механизм в этом контексте вряд ли впишется в Onion, потому как создает свой специфичный каркас (насколько я это понял).

Ну и если взять реальные примеры из жизни, можно привести проекты, для которых уже существовала база, то есть она была унаследованная. А CQRS + EventStore требует написание проекта полностью с нуля (?).
Опять же, подход Onion + DDD + Repository вполне может подойти для таких проектов, так как не требуе�� особой специфики кроме строгого выделения домена.

Оговорюсь, что я не ярый фанат Repository как может показаться Smile Просто пытаюсь понять для каких именно типов проектов возможно/оправдано использование CQRS

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

IMHO, вся реализация событий должна быть "под капотом" и скрыта от BL.

> Просто пытаюсь понять для каких именно типов проектов возможно/оправдано использование CQRS

Не стоит жестко связывать CQRS и работу с событиями. IMHO больше можно говорить о связи CQRS и шаблона Команда. Поэтому CQRS вполне способна заменить Repository в большинстве случаев.

Andrey

Спасибо за ответ. Ждем продолжения твоей статьи

Хотел сказать на счет Onion, CQRS и Repository.

Откуда растут ноги разделения двух/трех/... баз (а также поисковых движков и т.п.)? Если у вас были проекты, где в таблицах по десятку миллонов записей, то вы наверняка делали денормализацию данных, это неизбежно. Если не было, то дальше можно не читать Smile

Есть несколько вариантов для денормализации: 1. создать доп. колонки в текущих таблицах 2. делать отдельные таблицы/view для денормализованных данных 3. создать еще одну БД, куда будут скидываться денормализованные данные.

Выбор одного из вариантов зависит от вашего текущего положения на проекте. Если JOIN со всеми возможными ограничениями и оптмизациями успешно работает на текущих таблицах, в которые сразу и вставка/изменения идут, то вариант 1 подойдет. Если C-DU лочит денормализацию, то возможно уже надо о варианте 2 подумать. Если БД просто ложится, когда вы пытаетесь выборки и денормализации в нее делать, то вариант 3 (пример из моей недавней практики blog.byndyu.ru/2013/12/intergration-patterns.html)

Есть промежутки с NoSQL и поисковыми движками (sphinx, elastic) или key-velue (redis) для хранения справочников, MongoDB для выборки данных в UI и т.п., в любом случае это отдельные БД от основной и в них как-то должны попадать данные.

Теперь, что же нам делать в коде, когда появился такой зоопарк из хранилищ? C-UD операции у нас продолжают работать с rich domain model и со своим хранилищем, Repository и т.п. всё остается как было, возможно с NH или EF. А часть, которая нам нужна для -R-- работает например с dapper'ом и выбирает данные, которые нужны сразу для отображения, причем по варианту 3 выбирает уже из других хранилищ.

Дальше, вот у нас получилось, что мы уже читаем данных не из того же места, куда их записываем при обработке доменом. Встает 2 вопроса: 1. выбор метафоры для текущей архитектуры 2. как синхронизировать все хранилища?

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

Ответ 2: скорее всего вы выберете очереди, как самый удобный и надежный способ. По очередям будут передаваться сообщения с данными для обновления денормализованных/NoSQL хранилищю. Если вы считаете это сообщения событиями, то назовите их Event'ами. Тогда останется один шаг, вместо сохранения данных, храните Event'ы. Мы до этого шага так и не дошли, т.к. не было необходимости. Если у вас есть необходимость, то вот вы и с Event Sourcing'ом.

@ Александр Бындю: До десятков миллионов не доходил, но почитать было интересно.

@ Andrey:

Я на AgileDays 21-22 марта хочу эту тему подробнее раскрыть, надеюсь покажу с чем можно есть CQRS Smile

@ Александр Бындю: Класс, а она (конференция) онлайн будет транслироваться?

@ Andrey:

Я не знаю, лучше у организаторов уточнить.

Ислам 20.04.2014 18:11:37

@ Andrey:
Выражаю Вам огромную благодарность за статью. Сам я студент, начинающий программист .Net и С#. Я извиняюсь что влезаю в пост умных комментариев, но все таки, скажите, правильно ли мое убеждение, что смысл cqrs в том, чтобы запросы и команды писать в разных классах, а методы одновременно являющиеся запросом и командой просто исключить?

@ Ислам:
> чтобы запросы и команды писать в разных классах

Сам СQRS не навязывает стиль реализации. Он лишь говорит, что необходимо разделять команды и запросы. Например, можно создать класс хранилища, у которого будут команды и запросы как отдельные его методы.

> а методы одновременно являющиеся запросом и командой просто исключить

А вот это верно. Метод может быть или только командой или только запросом.

Ислам 22.04.2014 21:22:34

@ Andrey: Спасибо. Я всегда представлял CQRS как платформу или подключаемую к среде разработки библиотеку.. А это получается просто название подхода, с помощью которого можно построить код исключающий неожиданные результаты от таких методов как Pop();, и дальнейшее добавление запросов и команд упрощается значительно

@ Ислам: Верно. Ничего не мешает создать библиотеку, которая реализует CQRS. Но сам по себе CQRS это просто принцип создания той части приложения, которая отвечает за работу с данными.

И когда мы дождемся 2 части? Smile Или отказались все от CQRS?
Хотелось бы услышать мнения тех кто все еще использует и тех кто уже отказался.

AigizK Нет, от CQRS не отказался. Тем более что шаблон может охватывать несколько больше, чем только DAL. Насчет 2 части - будет. Код, собственно, уже есть и оформлен как библиотека. Осталось только описать его работу.

Приятно видеть такую статью, да еще и от земляка. Со времени её публикации появилось несколько фреймворков, Дино Эспозито написал об этом книгу. Да и вообще, в сообществе разработчиков, как мне кажется, этот подход вызывает всё больший интерес. В свое время я читал статьи Александра Бындю на эту тему - спасибо, что поделились опытом). Лично я натолкнулся на наиболее интересную реализацию принципов CQRS в блоге www.cuttingedge.it/.../entry.php?id=91

Есть еще статьи здесь https://abdullin.com/tags/cqrs/ Только он пишет на английском, хотя и с РФ.  

Будет вторая часть?

Код написан (даже живет на github), но есть желание его переписать пред публикацией. А вот времени пока маловато.

А где вторая частьSmile

Ответ чуть выше. Просто не хватает времени. Frown

Добавить комментарий