В этом посте я рассматриваю концепцию и ее реализацию (пока в начальной, но рабочей стадии), которая с недавних пор стала меня сильно привлекать. Опыта в программировании на сигналах у меня ранее не было, поэтому что-то мог упустить или неоптимально продумать, потому и пишу сюда. Надеюсь на квалифицированные отзывы и советы. Несмотря на то что библиотека только начала развиваться, я уже начал ее использование в реальных проектах, на реальной нагрузке, это помогает быстро понять что действительно нужно и куда двигаться дальше. Так что весь приведенный код находится в рабочем состоянии, компилируется и готов к использованию. Единственное все делается на Framework 4.5, но не думаю что это будет для кого-то препятствием, если же идея окажется стоящей, пересобрать под 3.5 проблем не будет.
Устройство обычного приложения на .NET подразумевает что у нас есть набор классов, в классах есть данные, и методы которые эти данные обрабатывают. Также нашим классам надо знать друг о друге, о public методах, свойствах и событиях. То есть у нас сильносвязная архитектура. Конечно мы можем уменьшить связность, построить взаимодействие исключительно через интерфейсы и фабрики (что увеличит размер кода раза в два, и существенно усложнит читабельность), можем убрать открытые методы и стоить все на событиях, придумать можно много чего, но перейти к слабосвязанной архитектуре все равно не выйдет, получим в лучшем случае «среднюю» связанность.
Да, и еще есть такая вещь, которая с развитием процессоров становится все более актуальной, это асинхронность, microsoft делает много хорошего в этом направлении, тот же PLINQ, всякий сахар вроде await, но все это делается все равно в привычных рамках ООП, и нам все еще приходится самим создавать потоки, пускай и в виде тасков, но самим. Нужно отслеживать окончание исполнения задач, чтобы определить когда рессурсы станут ненужными.
В общем все это постепенно надоедает, становится лень писать одни и те же вещи в каждом новом проекте, когда правильнее было бы сосредоточиться на логике задачи.
Для начала введем жесткое разделение, есть данные, и есть код бизнес-логики (далее просто логики), данные это классы, которые (внезапно), содержат в себе данные, и (раз уж у нас .NET, а не Эрланг), методы и свойства для облегчения их представления. Нет смысла полностью убирать методы, когда мы можем объединить плюсы двух подходов.
Классы логики оперируют данными, и обмениваются между собой при помощи сигналов. При этом, они не содержат никаких public методов, свойств или событий кроме конструктора и деструктора (или имплементации интерфейса IDisposable).
Также логично делать классы логики в виде синглтонов, но совсем необязательно, все зависит от задачи и вашего решения.
Класс логики содержит свои внутренние методы, и обработчики сигналов, также он имеет право сам генерировать новые сигналы, которые могут быть обработаны в любом другом классе логики (даже в нем же самом).
Сигнал представляет собой идентификатор, и, необязательно, какую-то полезную нагрузку (ссылку на данные в памяти).
Идентификатором сигнала можно сделать все что угодно, строку, GUID и.т.д., для себя же я выбрал в качестве него перечисление и его значение, в основном потому что люблю IntelliSense, лучшего пока не придумал. Также при таком подходе не получится ошибиться при генерации или подписке на сигнал, как, например, в случае строковых идентификаторов.
В качестве полезной нагрузки наиболее частым стоит ожидать передачу данных по ссылке, и здесь стоит соблюдать еще одно важное правило, данные не должны изменяться в обработчиках, пока это не контроллируется, и остается на совести программиста. Это правило вытекает из того что на один сигнал может быть подписано любое количество обработчиков в разных классах, и изменение данных в одном из них приведет к сбою в других. (Думаю как раз поэтому в Эрланге есть ограничение запрещающее переназначать переменные).
Еще одно важное правило, мы должны забыть о потоках/задачах, и о любом другом распараллеливании кода в классах логики, за это также отвечает библиотека, в следующем пункте будет показано как это достигается. Это требование особенно важно соблюдать, если нам требуется установить факт окончанися обработки сигнала всеми подписчиками.
Пример из кода ниже, у нас есть временное хранилище файла, в который файлы помещаются только на этап обработки, при появлении файла, хранилище подает сигнал, все подписчики начинают обработку (параллельно), читают файл, пишут лог о его появлении, после окончания обработки, хранилище получает (автоматически) сигнал о том что все кто хотел сделали все что хотели с этим файлом, и он больше никому не нужен, а значит его можно спокойно удалить. Если же какой-то обработчик создаст поток и вернет управление библиотеке, то файл может быть удален до того как код в созданном вручную потоке прочитает его, и мы получен трудноотлавливаемый сбой.
Инициализация:
Перечисление, значение которого используется как сигнал:
Пример класса (ссылка на код в конце статьи, сам код из задачи ниже) содержащего обработчик сигнала:
Пример генерации сигнала и обработчика завершения обработки сигнала всеми синхронными и асинхронными обработчиками.
Для указания обработчиков сигналов доступны следующие атрибуты:
Для генерации сигнала используется следующий формат:
или же
, наверное стоит придумать что-то красивее, пока сойдет.
Для удобства придумаем задачу, которую решим как при помощи предложенного подхода, и обычным способом для сравнения.
Задача:
Есть входной каталог, в котором появляются файлы, при появлении файла перемещаем его в буфер, проводим над ним действия, по окончании чего перемещаем его в архив и пишем в лог результат обработки. Какая обработка в данном случае неважно.
В качестве примера я написал два приложения решающих эту задачу, одно с использованием сигналов, второе обычное. Программы полностью идентичны, за исключением способа взаимодействия классов.
Наброски, в случае без сигналов
![](https://habrastorage.org/r/w1560/storage2/975/08b/6e0/97508b6e095e410c1960222dd8732ab6.png)
С использованием сигналов, где классы не знают о методах и событиях друг друга, и вообще не знают об окружении:
![](https://habrastorage.org/r/w1560/storage2/80a/900/515/80a9005159e51b218cebb967421c537c.png)
Графики из профилировщика для теста на 11210 файлах небольшого размера:
Без сигналов:
![](https://habrastorage.org/r/w1560/storage2/88e/b61/8dd/88eb618dd70d7a867070a0a5bfded26c.png)
С использованием сигналов:
![](https://habrastorage.org/r/w1560/storage2/2f4/f95/733/2f4f95733ae17375def64c9ffb73faca.png)
По графикам и по реальному использованию видно что урона производительности введение сигналов не принесло, скорее наоборот, обработка идет стабильно независимо от количества файлов.
В очередной раз убедился в универсальности языка C#, действительно на нем можно программировать с использованием любой парадигмы, а если и нет, то допилить его до нужного состояния.
Пока трудно судить насколько эффективно использование сигналов в среде .NET, тяжело сразу отбросить привычный стиль написания и начать думать в рамках новой модели. Субъективно нравится, код становится легче и асинхронность идет следствием новой модели, что тоже радует. Объективно – будет ясно со временем. На текущий момент ясно, что на производительности в худшую сторону не сказывается. Для себя решил, что буду пробовать перейти на эту модель программирования и продолжать развивать библиотеку и инструменты.
Уже перед публикацией нашел материалы по сигналам и слотам в QT, в целом идеи схожи, но в QT я не нашел можно ли определить факт окончания обработки сигнала из всех слотов.
Не знаю были ли уже попытки реализовать подобную модель на .NET, если были поделитесь ссылками, интересно сравнить подходы.
Проект на sourceforge (с английским все плохо, если найдете там ошибки, прошу отписать)
UPD#1: спасибо пользователю mayorovp, за дельное замечание по генерации сигналов и обработчикам, теперь можно писать обработчики с любым количеством аргументов, любого типа, и передавать эти аргументы при генерации сигнала (с проверкой соответствия передаваемых типов в runtime).
Примеры:
Падения производительности на тестах не замечено.
Что же не так с текущей парадигмой
Устройство обычного приложения на .NET подразумевает что у нас есть набор классов, в классах есть данные, и методы которые эти данные обрабатывают. Также нашим классам надо знать друг о друге, о public методах, свойствах и событиях. То есть у нас сильносвязная архитектура. Конечно мы можем уменьшить связность, построить взаимодействие исключительно через интерфейсы и фабрики (что увеличит размер кода раза в два, и существенно усложнит читабельность), можем убрать открытые методы и стоить все на событиях, придумать можно много чего, но перейти к слабосвязанной архитектуре все равно не выйдет, получим в лучшем случае «среднюю» связанность.
Да, и еще есть такая вещь, которая с развитием процессоров становится все более актуальной, это асинхронность, microsoft делает много хорошего в этом направлении, тот же PLINQ, всякий сахар вроде await, но все это делается все равно в привычных рамках ООП, и нам все еще приходится самим создавать потоки, пускай и в виде тасков, но самим. Нужно отслеживать окончание исполнения задач, чтобы определить когда рессурсы станут ненужными.
В общем все это постепенно надоедает, становится лень писать одни и те же вещи в каждом новом проекте, когда правильнее было бы сосредоточиться на логике задачи.
Формализация новых правил игры
Для начала введем жесткое разделение, есть данные, и есть код бизнес-логики (далее просто логики), данные это классы, которые (внезапно), содержат в себе данные, и (раз уж у нас .NET, а не Эрланг), методы и свойства для облегчения их представления. Нет смысла полностью убирать методы, когда мы можем объединить плюсы двух подходов.
Классы логики оперируют данными, и обмениваются между собой при помощи сигналов. При этом, они не содержат никаких public методов, свойств или событий кроме конструктора и деструктора (или имплементации интерфейса IDisposable).
Также логично делать классы логики в виде синглтонов, но совсем необязательно, все зависит от задачи и вашего решения.
Класс логики содержит свои внутренние методы, и обработчики сигналов, также он имеет право сам генерировать новые сигналы, которые могут быть обработаны в любом другом классе логики (даже в нем же самом).
Сигнал представляет собой идентификатор, и, необязательно, какую-то полезную нагрузку (ссылку на данные в памяти).
Идентификатором сигнала можно сделать все что угодно, строку, GUID и.т.д., для себя же я выбрал в качестве него перечисление и его значение, в основном потому что люблю IntelliSense, лучшего пока не придумал. Также при таком подходе не получится ошибиться при генерации или подписке на сигнал, как, например, в случае строковых идентификаторов.
В качестве полезной нагрузки наиболее частым стоит ожидать передачу данных по ссылке, и здесь стоит соблюдать еще одно важное правило, данные не должны изменяться в обработчиках, пока это не контроллируется, и остается на совести программиста. Это правило вытекает из того что на один сигнал может быть подписано любое количество обработчиков в разных классах, и изменение данных в одном из них приведет к сбою в других. (Думаю как раз поэтому в Эрланге есть ограничение запрещающее переназначать переменные).
Еще одно важное правило, мы должны забыть о потоках/задачах, и о любом другом распараллеливании кода в классах логики, за это также отвечает библиотека, в следующем пункте будет показано как это достигается. Это требование особенно важно соблюдать, если нам требуется установить факт окончанися обработки сигнала всеми подписчиками.
Пример из кода ниже, у нас есть временное хранилище файла, в который файлы помещаются только на этап обработки, при появлении файла, хранилище подает сигнал, все подписчики начинают обработку (параллельно), читают файл, пишут лог о его появлении, после окончания обработки, хранилище получает (автоматически) сигнал о том что все кто хотел сделали все что хотели с этим файлом, и он больше никому не нужен, а значит его можно спокойно удалить. Если же какой-то обработчик создаст поток и вернет управление библиотеке, то файл может быть удален до того как код в созданном вручную потоке прочитает его, и мы получен трудноотлавливаемый сбой.
Применение новых правил
Инициализация:
// Указываем сборку в которой классы бинес-логики отмечены атрибутом [rtSignalParticipator]
rtSignalCore.AppendAssembly(Assembly.GetEntryAssembly());
// Второй путь, указываем явно экзкмпляр класса бизнес-логики, в этом случае атрибут [rtSignalParticipator] не нужен
rtSignalCore.AppendTypeInstance(new FileHandler());
Перечисление, значение которого используется как сигнал:
/// <summary>
/// Сигналы от буфера
/// </summary>
public enum BufferSignal
{
/// <summary>
/// Появился новый файл в буфере
/// </summary>
FileInBuffer
}
Пример класса (ссылка на код в конце статьи, сам код из задачи ниже) содержащего обработчик сигнала:
// Аттрибут указывает что класс содержит обработчики сигналов,
// можно обойтись без него указав это явным образом
[rtSignalParticipator]
class FileHandler
{
// Аттрибут указывает что в методе ведется
// обработка сигнала BufferSignal.FileInBuffer
// и что обработка должна вызываться асинхронно
[rtSignalAsyncHanlder(BufferSignal.FileInBuffer)]
void ProcessFileInBuffer(rtSignal signal)
{
...
}
}
Пример генерации сигнала и обработчика завершения обработки сигнала всеми синхронными и асинхронными обработчиками.
[rtSignalAsyncHanlder(DirectoryWatcherSignal.ChangedDirectory)]
void NewFileHandler(rtSignal signal)
{
string path = (string)signal.State;
......................................................................
// Читаем файлы из входящего каталога и для каждого файла генерируем сигнал
// Генерация сигнала с передачей состояния
rtSignalCore.Signal(BufferSignal.FileInBuffer, filePath);
......................................................................
}
/// <summary>
/// Удаление файла из буфера по завершении его обработки всеми методами
/// </summary>
[rtSignalCompletedAsyncHanlder(BufferSignal.FileInBuffer)]
void RemoveFileFromBuffer(rtSignal signal)
{
string path = (string)signal.State;
if (File.Exists(path))
File.Delete(path);
}
Для указания обработчиков сигналов доступны следующие атрибуты:
- [rtSignalHanlder(SignalID)] – атрибут обработчика сигнала, который будет вызываться синхронно
- [rtSignalAsyncHanlder(SignalID)] – атрибут обработчика сигнала, который будет вызываться асинхронно
- [rtSignalCompletedHanlder(SignalID)] – атрибут метода получающего сигнал, когда все обработчики сигнала завершили свою работу (включая асинхронные)
- [rtSignalCompletedAsyncHanlder(SignalID)] – атрибут метода получающего сигнал, когда все обработчики сигнала завершили свою работу (включая асинхронные), метод выполняется асинхронно
Для генерации сигнала используется следующий формат:
rtSignalCore.Signal(идентификатор);
или же
rtSignalCore.Signal(идентификатор, полезная_нагрузка);
, наверное стоит придумать что-то красивее, пока сойдет.
Что решает подход с использованием сигналов
- Асинхронность становится следствием, и не требует дополнительных усилий, не требуется создания потоков/задач, все достигается разметкой обработчиков нужными атрибутами.
- Слабая связность кода, классам бизнес-логики вообще не требуется знать друг о друге, достаточно описать возможные сигналы.
- Простота тестирования отдельных компонентов, в связи с удалением жестких связей между классами.
- Легкость и читабельность кода
Пробуем применить на практике
Для удобства придумаем задачу, которую решим как при помощи предложенного подхода, и обычным способом для сравнения.
Задача:
Есть входной каталог, в котором появляются файлы, при появлении файла перемещаем его в буфер, проводим над ним действия, по окончании чего перемещаем его в архив и пишем в лог результат обработки. Какая обработка в данном случае неважно.
В качестве примера я написал два приложения решающих эту задачу, одно с использованием сигналов, второе обычное. Программы полностью идентичны, за исключением способа взаимодействия классов.
Наброски, в случае без сигналов
![](https://habrastorage.org/storage2/975/08b/6e0/97508b6e095e410c1960222dd8732ab6.png)
С использованием сигналов, где классы не знают о методах и событиях друг друга, и вообще не знают об окружении:
![](https://habrastorage.org/storage2/80a/900/515/80a9005159e51b218cebb967421c537c.png)
Графики из профилировщика для теста на 11210 файлах небольшого размера:
Без сигналов:
![](https://habrastorage.org/storage2/88e/b61/8dd/88eb618dd70d7a867070a0a5bfded26c.png)
С использованием сигналов:
![](https://habrastorage.org/storage2/2f4/f95/733/2f4f95733ae17375def64c9ffb73faca.png)
По графикам и по реальному использованию видно что урона производительности введение сигналов не принесло, скорее наоборот, обработка идет стабильно независимо от количества файлов.
Заключение.
В очередной раз убедился в универсальности языка C#, действительно на нем можно программировать с использованием любой парадигмы, а если и нет, то допилить его до нужного состояния.
Пока трудно судить насколько эффективно использование сигналов в среде .NET, тяжело сразу отбросить привычный стиль написания и начать думать в рамках новой модели. Субъективно нравится, код становится легче и асинхронность идет следствием новой модели, что тоже радует. Объективно – будет ясно со временем. На текущий момент ясно, что на производительности в худшую сторону не сказывается. Для себя решил, что буду пробовать перейти на эту модель программирования и продолжать развивать библиотеку и инструменты.
Уже перед публикацией нашел материалы по сигналам и слотам в QT, в целом идеи схожи, но в QT я не нашел можно ли определить факт окончания обработки сигнала из всех слотов.
Не знаю были ли уже попытки реализовать подобную модель на .NET, если были поделитесь ссылками, интересно сравнить подходы.
Проект на sourceforge (с английским все плохо, если найдете там ошибки, прошу отписать)
UPD#1: спасибо пользователю mayorovp, за дельное замечание по генерации сигналов и обработчикам, теперь можно писать обработчики с любым количеством аргументов, любого типа, и передавать эти аргументы при генерации сигнала (с проверкой соответствия передаваемых типов в runtime).
Примеры:
- Без аргументов
// Генерация rtSignalCore.Signal(SignalIdentifierEnum.One); ................... // Обработчик [rtSignalHanlder(SignalIdentifierEnum.One)] void HandlerSignalClassBOne()
- Пример с передачей одного аргумента типа string
// Генерация "Hello world".SendSignal(SignalIdentifierEnum.One); ................... // Обработчик [rtSignalHanlder(SignalIdentifierEnum.One)] void HandlerSignalClassBOne(string line)
- Пример с передачей двух аргументов
// Генерация rtSignalCore.Signal(SignalIdentifierEnum.One, filePath, new FileInfo(filePath)); ................... // Обработчик [rtSignalHanlder(SignalIdentifierEnum.One)] void HandlerSignalClassBOne(string filePath, FileInfo file)
Падения производительности на тестах не замечено.
Only registered users can participate in poll. Log in, please.
Ваша оценка работы и подхода
68.89% Стоит продолжать, имеет право на существование93
8.89% Подход хороший, реализация неверна (укажите свой вариант в комментах)12
13.33% Подход неприменим в среде .NET18
8.89% Подход неприменим в среде .NET и плохая реализация (укажите свой вариант в комментах)12
135 users voted. 130 users abstained.