вторник, 9 декабря 2008 г.

Привет, меня зовут Дмитрий Немцов. Хочу рассказать об одном антипаттерне, который не описан в имеющейся литературе, однако несколько раз был обнаружен в самых разных местах нашего проекта практически в неизменной виде. Я назвал его "Обиженный новичок". Кратко опишу суть этого антипаттерна.
Пусть имеется некий поставщик событий. На события подписываются и отписываются объекты - слушатели. Генерация события происходит при изменении состояния поставщика событий (приходе данных, редактировании настроек и т.п.).
Все подписанные объекты получают уведомления и тоже изменяют свое состояние. Новый объект - слушатель, подписавшийся к поставщику, не получит текущего состояния, пока оно не изменится. Состояние слушателя будет тем же, что и до подписки. Такое состояние корректно лишь в том случае, если до подписки этого объекта не было ни одного события.
Подобная ошибка может закрасться при проектировании моделей подписки на события, ориентированных на большую частоту поступления событий (это могут быть, например, биржевые данные реального времени, поступающие с частотой 10 - 100 порций в секунду). При этом ошибочное состояние нового подписчика остается незамеченным, поскольку оно длится недолго.
Проблемы начинаются тогда, когда частота поступления событий падает (например, до нуля, когда прекращаются торги).
При проверке незнакомого кода на предмет того, не кроется ли в нем описанный антипаттерн, можно руководствоваться следующим контрольным списком:
1. Проверить, что поставщики никак не могут инициализировать свое текущее состояние, кроме как через обратные вызовы их обработчиков. Это можно выяснить, взглянув на объявление интерфейсов слушателей, например:

interface ISomeEventListener
{
void OnSomeEvent(const SomeEvent& event) = 0;
}


2. Проверить, что происходит в коде поставщика событий в момент подписки нового слушателя. Зачастую такой код имеет вид
EventProvider::SubscribeListener(ISomeEventListener* newListener)
{
...
m_listeners.Add(newListener);
...
}

В окрестностях этого кода нужно поискать две вещи:
a) прямой вызов у нового подписчика обработчика события с неким закэшированным состоянием
...
m_listeners.Add(newListener);
...
newListener.OnSomeEvent(m_cashedEvent);
...

б) код, корорый как - либо запоминает нового подписчика (отдельно от старых подписчиков). Это может быть сделано с целью исключения непосредственного вызова обработчика у нового подписчика
...
m_newListeners.Add(newListener);
...


в этом случае вызов обработчика будет располагаться в другом методе и сопровождаться переносом нового подписчика к старым
...
m_newListeners[0]->OnSomeEvent(m_cashedEvent);
m_listeners.Add(m_newListeners[0]);
m_newListeners.Clear();
...


Приведу реальные примеры из нашего проекта.

I.
В нашем коде есть сущность под названием FontMgr. В ней объединены настройки шрифтов для всех окон. Вот её обработчик подписки(до исправления):

Видно, что нет никакого способа получить текущий шрифт, кроме как по событию его изменения.

interface __declspec(uuid("7EF51E4B-28C3-4d86-9E55-F81E75235731")) IFontMgrEvents
{
virtual HRESULT STDMETHODCALLTYPE OnFontChanged(int index, LPCFONTSTRUCT font) = NULL;
virtual HRESULT STDMETHODCALLTYPE OnUseSettingsAsDefaults() = NULL;
};


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

HRESULT CFontMgr::Advise(IFontMgrEvents* e)
{
int index = m_events.Find(e);

if (-1 != index)
{
return S_FALSE;
}

m_events.Add(e);
return S_OK;
}

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

HRESULT CFontMgr::Advise(IFontMgrEvents* e)
{
int index = m_events.Find(e);

if (-1 != index)
{
return S_FALSE;
}

m_events.Add(e);
int fontsSize = m_fonts.GetSize();
if (0 < fontsSize)
{
for (int i = 0; i < fontsSize; ++i)
{
e->OnFontChanged(i, (LPCFONTSTRUCT)&m_fonts[i].font); // сообщение текущего состояния новому подписчику
}
}
return S_OK;
}

II.

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

interface IBarListener
{
virtual void OnBarBackFill(IBarSet *bs)=0;
virtual void OnBarBackFillComplete(IBarSet *bs)=0;
};


После того, как заинтересованный в данных объект подпишется у поставщика

dataProvider->SubscribeBars(SubscriptionParams params, this)

данные начнут приходить к нему через обратные вызовы обработчиков. IBarSet представляет совокупность данных. Когда приходит очередная порция данных, вызывается OnBarBackFill. Он может вызываться много раз, в зависимости от объема запрошенных данных. После прихода последней порции данных, вызывается OnBarBackFillComplete. Приходящие данные кэшируются в поставщике событий. Если подписка на данные осуществляется не в первый раз, т.е. данные уже есть в кэше, то OnBarBackFillComplete не вызывается. Это является архитектурной проблемой, которая к настоящему моменту не исправлена. Чтобы узнать, полностью ли пришли данные, каждый слушатель должен каждый раз при вызове OnBarBackFill проверять у контейнера с данными свойство IsCompleted() и в зависимости от него принимать решение, что делать.

void SomeDataConsumer::OnBarBackFill(IBarSet *bs)
{
if (bs->IsCompleted())
{
UpdateGUI();
}
else
{
DoSomeCalculations();
}
}


Отрицательные последствия данного подхода очевидны. Во - первых, возникает дублирование кода. Во - вторых, OnBarBackFillComplete практически теряет смысл, так как ведет себя неодинаково для всех подписчиков.

III.

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

Комментариев нет: