Вам бонус- начислено 1 монета за дневную активность. Сейчас у вас 1 монета

The Command and Query Responsibility Segregation (CQRS) - шаблон разделения ответственности и Event Sourcing

Лекция



Привет, Вы узнаете о том , что такое cqrs, Разберем основные их виды и особенности использования. Еще будет много подробных примеров и описаний. Для того чтобы лучше понимать что такое cqrs, шаблон разделения ответственности, event sourcing, эвент-сорсинг , настоятельно рекомендую прочитать все из категории Разработка программного обеспечения и информационных систем.

The Command and Query Responsibility Segregation (CQRS) - шаблон разделения ответственности на команды и запросы. Разделяет операции чтения и обновления для хранилища данных. Внедрение CQRS в приложение может максимизировать его производительность, масштабируемость и безопасность. Гибкость, создаваемая переходом на CQRS, позволяет системе лучше развиваться с течением времени и не позволяет командам обновления вызывать конфликты слияния на уровне домена.

Шаблон CQRS применяет принцип императивного программирования разделения команд и запросов - сommand-query separation (CQS), используя отдельные объекты запросов и команд для извлечения и изменения данных соответственно . CQS введен Бертраном Мейером во время работы над языком программирования Eiffel. Принцип гласит, что метод должен быть либо командой, выполняющей какое-то действие, либо запросом, возвращающим данные, но не одновременно. Другими словами, задавание вопроса не должно менять ответ. Более формально, возвращать значение можно только чистым (т.е. детерминированным и не имеющим побочных эффектов) методом. Следует отметить, что строгое соблюдение этого принципа делает невозможным отслеживание количества вызовов запросов.

Применение в контрактном программировании

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

Теоретически, это дает возможность узнавать состояние программы, не меняя его. На практике, CQRS дает возможность пропустить все проверки утверждений в действующей системе, чтобы повысить ее производительность, не боясь того, что это изменит ее поведение. CQRS также предотвращает возникновение некоторых гейзенбагов.

Другие применения

Даже за пределами контрактного программирования, применение CQRS рассматривается его приверженцами как оказывающее эффект упрощения на программу, делая доступ к ее состоянию (через запросы) и изменение ее состояния (через команды) более понятными, аналогично тому как избежание использования goto упрощает понимание потока выполнения программы.

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

Недостатки

CQS может осложнить создание реентерабельного и многопоточного ПО. Эта проблема обычно возникает при использовании непотокобезопасного шаблона для реализации CQS.

Простой пример шаблона, нарушающего CQS, но полезного в многопоточном ПО:

private int x;
public int increment_and_return_x()
{
  lock x;   // какой-либо механизм блокировки
  x = x + 1;
  int x_copy = x;
  unlock x; // какой-либо механизм блокировки
  return x_copy;
}

Распространенный CQS шаблон, применимый только в однопоточных приложениях:

private int x;
public int value()
{
  return x;
}
void increment_x()
{
  x = x + 1;
}

Даже в случае однопоточных программ иногда можно утверждать, что значительно более удобно иметь метод, объединяющий запрос и команду. Мартин Фаулер приводит метод стека pop() в качестве примера.

мне сразу сказали что на проекте, над которым я буду работать используется CQRS, Event Sourcing, и MongoDB в качестве базы данных. Из этого всего я слышал только о MongoDB. Попытавшись вникнуть в CQRS, я не сразу понял все тонкости данного подхода, но почему-то мне понравилась идея разделения модели взаимодействия с данными на две — read и write. Возможно потому что она как-то перекликалась с парадигмой программирования “разделение обязанностей”, возможно потому что была очень в духе DDD.

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

Сразу хочу уточнить что я работал только со связкой CQRS + Event Sourcing, и никогда не пробовал просто CQRS, так как мне кажется что без Event Sourcing он теряет очень много бенефитов. В качестве CQRS фреймворка я буду использовать наш корпоративный Paralect.Domain. Он чем-то лучше других, чем то хуже. В любом случае советую вам ознакомиться и с остальными. Я здесь упомяну только несколько фреймворков для .NET. Наиболее популярные это NCQRS, Lokad CQRS, SimpleCQRS. Так же можете посмотреть на Event Store Джонатана Оливера с поддержкой огромного количества различных баз данных.

Начнем с CQRS


Что же такое CQRS?
CQRS расшифровывается как Command Query Responsibility Segregation (разделение ответственности на команды и запросы). Это паттерн проектирования, о котором я впервые услышал от Грега Янга (Greg Young). В его основе лежит простое понятие, что вы можете использовать разные модели для обновления и чтения информации. Однако это простое понятие ведет к серьезным последствиям в проектировании информационных систем. (с) Мартин Фаулер

Не сказать что исчерпывающее определение, но сейчас я попробую объяснить что именно Фаулер имел в виду.
К настоящему времени сложилась такая ситуация что практические все работают с моделью данных как с CRUD хранилищем. CQRS предлагает альтернативный подход, но затрагивает не только модель данных. Если вы используете CQRS, то это сильно отражается на архитектуре вашего приложения.

Вот как я изобразил схему работы CQRS
The Command and Query Responsibility Segregation (CQRS) - шаблон разделения ответственности и Event Sourcing

Первое что бросается в глаза это то что у вас уже две модели данных, одна для чтения (Queries), одна для записи (Commands). И обычно это значит что у вас еще и две базы данных. И так как мы используем CQRS + Event Sourcing, то write-база (write-модель) — это Event Store, что-то вроде лога всех действий пользователя (на самом деле не всех, а только тех которые важны с точки зрения бизнес-модели и влияют на построение read-базы). А read-база — это в общем случае денормализировнное хранилище тех данных, которые вам нужны для отображения пользователю. Почему я сказал что read-база денормализированная? Вы конечно можете использовать любую структуру данных в качестве read-модели, но я считаю что при использовании CQRS + Event Sourcing не стоит сильно заморачиваться над нормализвацией read-базы, так как она может быть полностью перестроена в любое время. И это большой плюс, особенно если вы не хотите использовать реляционные базы данных и смотрите в сторону NoSQL.
Write-база вообще представляет собой одну коллекцию ивентов. То есть тут тоже нету смысла использовать реляционную базу.

Event Sourcing


Идея Event Sourcing в том чтобы записывать каждое событие, которое меняет состояние приложения в базу данных. Таким образом получается что мы храним не состояние наших сущностей, а все события которые к ним относятся. Однако мы привыкли к тому чтобы манипулировать именно состоянием, оно храниться у нас в базе и мы всегда можем его посмотреть.
В случае с Event Sourcing мы тоже оперируем с состоянием сущности. Но в отличии от обычной модели мы это состоянием не храним, а воспроизводим каждый раз при обращении.

The Command and Query Responsibility Segregation (CQRS) - шаблон разделения ответственности и Event Sourcing

Если посмотреть на код, который поднимает агрегат из базы, можно и не заметить какую-то разницу с традиционным подходом.

var user = Repository.Get(userId);



На самом же деле репозиторий не берет из базы готовое состояние агрегата UserAR (AR = Aggregate Root), он выбирает из базы все события которые ассоциируются с этим юзером, и потом воспроизводит их по порядку передавая в метод On() агрегата.

Например у класса агрегата UserAR должен быть следующий метод, для того чтобы восстановить в состоянии пользователя его ID

protected void On(User_CreatedEvent created)
{
            _id = created.UserId;
}



Из всего состояния агрегата мне нужна только _id юзера, так же я мог бы восстановить состояние пароля, имени и т.д. Об этом говорит сайт https://intellect.icu . Однако эти поля могут быть модифицированы и другими событиями, не только User_CreatedEvent соответственно мне нужно будет обработать их все. Так как все события воспроизводятся по порядку, я уверен, что всегда работаю с последним актуальным состоянием агрегата, если я конечно написал обработчики On() для всех событий которые это состояние изменяют.

Давайте рассмотрим на примере создания пользователя как работает CQRS + Event Sourcing.

Создание и отправка команды


Первое что я сделаю — это сформирую и отправлю команду. Для простоты пусть команда создания юзера имеет только самый необходимый набор полей и выглядит следующим образом.

public class User_CreateCommand: Command
    {
        public string UserId { get; set; }

        public string Password { get; set; }

        public string Email { get; set; }
    }


Пусть вас не смущает имя класса команды, оно не соответствует общепринятым гайдлайнсам, но позволяет сразу понять к какому агрегату она относится, и какое действие выполняется.

var command = new User_CreateCommand
                              {
                                  UserId = “1”,
                                  Password = “password”,
                                  Email = “test@test.com”,

                              };
            command.Metadata.UserId = command.UserId;
            _commandService.Send(command);



Затем мне нужен обработчик этой команды. Обработчику команды обязательно нужно передать ID нужного агрегата, по этому ID он получит агрегат из репозитория. Репозиторий строит объект агрегата следующим образом: берет из базы все события которые относятся к этому агрегату, создает новый пустой объект агрегата, и по порядку воспроизведет полученные события на объекте агрегата.
Но так как у нас команды создания — поднимать из базы нечего, значит создаем агрегат сами и передаем ему метаданные команды.

public class User_CreateCommandHandler: CommandHandler
    {
        public override void Handle(User_CreateCommand message)
        {
            var ar = new UserAR(message.UserId, message.Email, message.Password, message.Metadata);
            Repository.Save(ar);
        }
    }



Посмотрим как выглядит конструктор агрегата.

public UserAR(string userId, string email, string password, ICommandMetadata metadata): this()
        {
            _id = userId;
            SetCommandMetadata(metadata);
            Apply(new User_CreatedEvent
            {
                UserId = userId,
                Password = password,
                Email = email
            });
        }



Так же у агрегата обязательно должен быть конструктор без параметров, так как когда репозиторий воспроизводит состояние агрегата, ему надо сначала создать путой экземпляр, а затем передавать события в методы проекции (метод On(User_CreatedEvent created) является одним из методов проекции).
Немного уточню на счет проекции. Проекция — это воспроизведение состояния агрегата, на основе событий из Event Store, которые относятся к этом агрегату. В примере с пользователем — это все события для данного конкретного пользователя. А агрегате все те же события которые сохраняются через метод Apply, можно обработать во время воспроизведения его состояния. В нашем фреймворке для это достаточно написать метод On(/*EventType arg*/), где EventType — тип события которое вы хотите обработать.

Метод Apply агрегата инициирует отправку событий всем обработчикам. На самом деле события будут отправлены только при сохранение агрегата в репозиторий, а Apply просто добавляет их во внутренний список агрегата.
Вот обработчик события(!) создания пользователя, который записывает в read базу собственно самого пользователя.

public void Handle(User_CreatedEvent message)
        {
            var doc = new UserDocument
                          {
                              Id = message.UserId,
                              Email = message.Email,
                              Password = message.Password
                          };
            _users.Save(doc);
        }



У события может быть несколько обработчиков. Такая архитектура помогает сохранять целостность данных, если ваши данные сильно денормализированы. Допустим мне надо часто показывать общее количество пользователей. Но у меня их слишком много и операция count по всем очень затратна для моей БД. Тогда я могу написать еще один обработчик события, который будет уже относится к статистике и каждый раз при добавлении пользователя будет увеличивать общий счетчик пользователей на 1. И я буду уверен, что никто не создаст пользователя, не обновив при этом счетчик. Если бы я не использовал CQRS, а была бы у меня обычная ORM, пришлось бы следить в каждом месте где добавляется и удаляется пользовать чтоб обновился и счетчик.
А использование Event Sourcing’а дает мне дополнительные приемущеста. Даже если я ошибся в каком-то EventHandler’е или не обработал событие везде где мне это надо, я могу легко это исправать просто перегенировав read базу с уже правильной бизнесс логикой.

С созданием понятно. Как происходит изменение агрегата и валидация команды? Рассмотрим пример со сменой пароля.
Я приведу только command handler и метод агрегата ChangePassword(), так как в остальных местах разница в общем не большая.

Command Handler

public class User_ChangePasswordCommandHandler: IMessageHandler
    {
// Конструктор опущен
        public void Handle(User_ChangePasswordCommand message)
        {
// берем из репозитория агрегат
            var user = _repository.GetById(message.UserId);
// выставляем метаданные
            user.SetCommandMetadata(message.Metadata);
// меняем пароль
            user.ChangePassword(message.OldPassword, message.NewPassword);
// сохраняем агрегат
            _repository.Save(user);
        }
    }

Aggregate Root

public class UserAR : BaseAR
    {
      //...

        public void ChangePassword(string oldPassword, string newPassword)
        {
// Если пароль не совпадает со старым, кидаем ошибку
            if (_password != oldPassword)
            {
                throw new AuthenticationException();
            }
// Если все ОК - принимаем ивент
            Apply(new User_Password_ChangedEvent
                      {
                          UserId = _id,
                          NewPassword = newPassword,
                          OldPassword = oldPassword
                      });
        }


// Метод проекции для восстановления состояния пароля
        protected void On(User_Password_ChangedEvent passwordChanged)
        {
            _password = passwordChanged.NewPassword;
        }

// Так же добавляем восстановления состояния пароля на событие создания пользователя
         protected void On(User_CreatedEvent created)
        {
            _id = created.UserId;
            _password = created.Password;
        }
    }
}



Хочу заметить что очень не желательно чтобы невалидное событие было передано в метод Apply(). Конечно вы сможете его обработать потом в event handler’е, но лучше вообще его не сохранять, если оно вам не важно, так как это только засорит Event Store.
В случае со сменой пароля вообще нет никакого смысла сохранять это событие, если вы конечно не собираете статистку по неудачным сменам пароля. И даже в этом случае следует хорошенько подумать, ножно ли это событие вам во write модели или есть смысл записать его в какое-нибудь темповое хранилище. Если вы предполагаете что бизнес логика валидации события может измениться то тогда сохраняйте его.

Вот собственно и все о чем я хотел рассказать в этой статье. Конечно она не раскрывает все аспекты и возможности CQRS + Event Sourcing, об этом я планирую рассказать в следующих статьях. Так же остались за кадром проблемы которые возникают при использовании данного подхода. И об этом мы тоже поговорим.
Если у вас есть какие-то вопросы, задавайте их в комментариях. С радостью на них отвечу. Так же если есть какие-то предложения по следующим статьям — очень хочется их услышать.

Sources


Полностью рабочий пример на ASP.NET MVC находится здесь.
Базы данных там нету, все храниться в памяти. При желании ее достаточно просто прикрутить. Так же из коробки есть готовая реализация Event Store на MongoDB для хранения ивентов.
Чтобы ее прикрутить достаточно в Global.asax файле заменить InMemoryTransitionRepository на MongoTransitionRepository.
В качестве read модели у меня статическая коллекция, так что при каждом перезапуске данные уничтожаются.

What's Next?


У меня есть несколько идей на счет статей по данной тематике. Предлагайте еще. Говорите что наиболее интересно.

  • Что такое Snapshot’ы, зачем нужны, детали и варианты реализации.
  • Event Store.
  • Регенерация базы данных. Возможности. Проблемы, производительность. Распараллеливание. Патчи.
  • Дизайн Aggregate Root’ов.
  • Применение на реальных проектах. Один проект на аутсорсинге, второй — мой стартап.
  • Особенности интеграции сторонних сервисов.

Последнее время получают распространение событийно-ориентированные архитектуры (event-driven architectures) и, в частности, Event Sourcing (порождение событий). Эта вызвано стремлением к созданию устойчивых и масштабируемых модульных систем. В этом контексте довольно часто используется термин “микросервисы”. На мой взгляд, микросервисы — это всего лишь один из способов реализации “ограниченного контекста” (Bounded Context). Очень важно правильно определить границы модулей и в этом помогает стратегическое проектирование (Strategic Design), описанное Эриком Эвансом в Domain Driven Design. Оно помогает вам идентифицировать / обнаружить модули, границы (“ограниченный контекст”) и описать, как эти контексты связаны друг с другом (карта контекстов, ContextMap).

События предметной области как основа единого языка


Хотя в книге Эрика Эванса это явно не обозначено, но события предметной области очень хорошо содействуют концепциям DDD. Такие практики как Event Storming Альберто Брандолини смещают акцент у событий с технического на организационный и бизнес-уровень. Здесь мы говорим не о событиях пользовательского интерфейса, таких как щелчок на кнопке (ButtonClickedEvent), а о доменных событиях, которые являются частью предметной области. О них говорят и их понимают эксперты в предметной области. Эти события представляют собой первоочередные концепции и помогают сформировать единый язык (ubiquitous language), с которым будут согласны все участники (эксперты в предметной области, разработчики и т.д.).

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


Доменные события могут использоваться для взаимодействия между ограниченными контекстами. Предположим, у нас есть интернет-магазин с тремя контекстами: Order (заказ), Delivery (доставка), Invoice (счет).

Рассмотрим событие “Заказ принят” в контексте Order. Контекст Invoice, а также контекст Delivery заинтересованы в отслеживании этого события, так как это событие инициирует некоторые внутренние процессы в этих контекстах.

Миф о слабой связанности


Использование доменных событий помогает разрабатывать слабосвязанные модули. Отдельные модули могут быть временно не доступны. Но для доменного события совершенно не важно доступны они или нет, так как событие только описывает то, что произошло в прошлом. Другие модули сами решают, когда обработать событие. У вас по умолчанию получается гибкая система.

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

Это здорово, но сложность заключается в решении того, какие данные хранить в событии?

Простой ответ: Event Sourcing!


События полезны, так почему бы не использовать их по максимуму. Это основная идея Event Sourcing. Вы храните состояние агрегата не через обновление его данных (CRUD), а через применение потока событий.
Помимо того, что вы можете воспроизвести события и получить состояние, есть еще одна особенность Event Sourcing: вы бесплатно получаете полный журнал аудита. Поэтому, когда требуется такой журнал, при выборе стратегии хранения обязательно обратите внимание на Event Sourcing.

эвент-сорсинг — это только уровень хранения

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

The Command and Query Responsibility Segregation (CQRS) - шаблон разделения ответственности и Event Sourcing

6. От снимков состояний к событиям

Когда состояние системы хранится в виде снимков состояния — состояние объекта в системе представлено изменяемой строкой или строками в базе данных. При обновлении состояния объекта мы перезаписываем строку новыми значениями.

Когда применяется эвент-сорсинг — состояние хранится в виде последовательности неизменяемых событий. При обновлении состояния объекта нужно сохранить новые события не меняя старые.

Событие в эвент-сорсинге это набор данных описывающий некоторый факт реального мира, когда событие уже произошло, как и в реальном мире, оно уже не может измениться.

В коде это некоторый неизменяемый объект,

Вам может показаться странным, что я сразу перешел от доменных событий к хранению, так как, очевидно, что это концепции разных уровней.

… и вот моя точка зрения: Event Sourcing — это локальное решение, используемое только в одном ограниченном контексте! События Event Sourcing не должны выдаваться наружу во внешний мир! Другие ограниченные контексты не должны знать о способах хранения данных друг друга, и поэтому им не важно, использует ли какой-то контекст Event Sourcing.

Если вы используете Event Sourcing глобально, то вы раскрываете свой уровень хранения.

Способ хранения данных становится вашим публичным API. Каждый раз при внесении изменений в уровень хранения вам придется иметь дело с изменением публичного API.

Я уверен, все согласятся с тем, что плохо, когда разные ограниченные контексты совместно используют данные в (реляционной) базе данных из-за возникающей связанности. Но чем это отличается от Event Sourcing? Ничем. Не имеет значения, используете вы общие события или общие таблицы в базе данных. В обоих случаях вы разделяете детали хранения.

Выход есть


Я все еще утверждаю, что доменные события идеально подходят для взаимодействия между ограниченными контекстами, но эти события не должны быть связаны с событиями, которые используются для Event Sourcing.

Предлагаемое решение очень простое: независимо от того, какой подход вы используете для хранения данных (CRUD или Event Sourcing), вы публикуете доменные события в глобальном хранилище событий. Эти события представляют собой публичное API вашего контекста. При использовании Event Sourcing вы храните события Event Sourcing в своем локальном хранилище, доступном только для этого ограниченного контекста.

Свобода выбора


Наличие отдельных доменных событий в публичном API позволяет вам гибко их моделировать. Вы не ограничены моделью, которая предопределена событиями Event Sourcing.

Есть два варианта для работы с ”событиями реального мира”: служба с открытым протоколом и общедоступным языком (Open Host Service, Published Language) или Заказчик / Поставщик (Customer/Supplier).

Служба с открытым протоколом и общедоступным языком (Open Host Service, Published Language)


Публикуется только одно доменное событие, содержащее все данные, которые могут понадобиться другим ограниченным контекстам. В терминологии DDD это можно назвать службой с открытым протоколом (Open Host Service) и общедоступным языком (Published Language).

The Command and Query Responsibility Segregation (CQRS) - шаблон разделения ответственности и Event Sourcing

Наступление события реального мира “Заказ принят” приводит к тому, что публикуется одно доменное событие OrderAccepted. Полезная нагрузка этого события содержит все данные о заказе, которые, могут понадобиться другим ограниченным контекстам… так что, надеюсь, контексты Invoice и Delivery найдут всю необходимую им информацию.

Заказчик / Поставщик (Customer/Supplier)


Для каждого потребителя публикуются отдельные события. Необходимо согласовать модели каждого события только с одним потребителем, не требуется определять общую разделяемую модель. DDD называет эти отношения Заказчик / Поставщик (Customer/Supplier).

The Command and Query Responsibility Segregation (CQRS) - шаблон разделения ответственности и Event Sourcing

Возникновение события реального мира “Заказ принят” приводит к публикации отдельных событий для каждого из потребителей: InvoiceOrderAccepted и DeliveryOrderAccepted. Каждое доменное событие содержит только те данные, которые необходимы контексту-получателю.

Я не хочу сейчас обсуждать плюсы и минусы этих подходов. Я хочу просто обратить внимание на то, что можно выбирать количество доменных событий и данные, которые в них хранить.

Это преимущество, которое вы не должны недооценивать, потому что можно решить, как развивать API вашего ограниченного контекста без привязки к событиям Event Sourcing.

Заключение


Выставление наружу деталей хранения — хорошо известный анти-паттерн. Говоря о хранении, мы в первую очередь думаем о таблицах базы данных, но мы увидели, что события, используемые для Event Sourcing, являются лишь еще одним способом хранения данных. Поэтому выдавать их наружу тоже является анти-паттерном.

The Command and Query Responsibility Segregation (CQRS) - шаблон разделения ответственности и Event Sourcing
Перевод: “хороший разработчик как оборотень — боится серебряных пуль”.

Event Sourcing — это мощный подход, если используется правильно (локально). На первый взгляд кажется, что для событийно-ориентированных архитектур это серебряная пуля, но если присмотреться внимательнее, то видно, что этот подход может привести к сильной связанности … чего вы, конечно, не хотите.

Вау!! 😲 Ты еще не читал? Это зря!

Исследование, описанное в статье про cqrs, подчеркивает ее значимость в современном мире. Надеюсь, что теперь ты понял что такое cqrs, шаблон разделения ответственности, event sourcing, эвент-сорсинг и для чего все это нужно, а если не понял, или есть замечания, то не стесняйся, пиши или спрашивай в комментариях, с удовольствием отвечу. Для того чтобы глубже понять настоятельно рекомендую изучить всю информацию из категории Разработка программного обеспечения и информационных систем

Ответы на вопросы для самопроверки пишите в комментариях, мы проверим, или же задавайте свой вопрос по данной теме.

создано: 2021-05-24
обновлено: 2024-11-25
63



Рейтиг 9 of 10. count vote: 2
Вы довольны ?:


Поделиться:

Найди готовое или заработай

С нашими удобными сервисами без комиссии*

Как это работает? | Узнать цену?

Найти исполнителя
$0 / весь год.
  • У вас есть задание, но нет времени его делать
  • Вы хотите найти профессионала для выплнения задания
  • Возможно примерение функции гаранта на сделку
  • Приорететная поддержка
  • идеально подходит для студентов, у которых нет времени для решения заданий
Готовое решение
$0 / весь год.
  • Вы можите продать(исполнителем) или купить(заказчиком) готовое решение
  • Вам предоставят готовое решение
  • Будет предоставлено в минимальные сроки т.к. задание уже готовое
  • Вы получите базовую гарантию 8 дней
  • Вы можете заработать на материалах
  • подходит как для студентов так и для преподавателей
Я исполнитель
$0 / весь год.
  • Вы профессионал своего дела
  • У вас есть опыт и желание зарабатывать
  • Вы хотите помочь в решении задач или написании работ
  • Возможно примерение функции гаранта на сделку
  • подходит для опытных студентов так и для преподавателей

Комментарии


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

Разработка программного обеспечения и информационных систем

Термины: Разработка программного обеспечения и информационных систем