Попробую описать процесс проекции плоского API, написанного в процедурном стиле, в многомерное ООП пространство, на примере работы, которой было занято почти все мое свободное время за последний месяц.
Возможно многим эта заметка покажется интересной не с точки зрения содержания, сколько с точки зрения результата работы, которым я готов поделиться со всеми, а именно - .Net обертка над замечательной SIP библиотекой pjsip, но для меня она ценна именно как описание процесса. Можно сказать мемуар.
Конечно обертка эта не покрывает все то богатство возможностей управления процессом, которое предоставляют различные модули pjsip, но предоставляет высокоуровневый .Net интерфейс к pjsua (user agent) API, необходимый для быстрого создания SIP клиентов.
- P/Invoke или как не утонуть в тоннах кода;
- Designing API;
- Testing API;
Я пропустил очень важную часть - выбор подходящей библиотеки, т.к. считаю, что к теме заметки это имеет мало отношения, но все-таки немного опишу мотивацию, которая диктовала выбор SIP библиотеки pjsip. Мне нужна библиотека:
- реализующая последние RFC в наиболее полном объеме;
- с поддержкой различных кодеков, в том числе широкополосных;
- портируемая на разные платформы (на будущее);
- с возможностью передачи видео-потока (на будущее);
- open source с разумной лицензией, которая позволила бы использовать ее в коммерческих приложениях. (UPD3: GPL, под которой распространяется эта библиотека не такая уж и разумная, а, как сказал мой друг: "детище неадекватного проповедника Столмена". Следует заметить, что исходное выражение подверглось жесткой цензуре =). )
Итак, прежде всего нам надо скачать исходные коды и собрать dll. Скачать pjsip можно из SVN хранилища, как стабильную версию, так и текущий закоммиченый код.
Далее, забегая вперед, сразу же скажу, что при генерации interop-кода возникла проблема с callback функциями, т.к. C# поддерживает только stdcall callback функции (UPD: врешь ты все =) ), т.е. делегаты будут вызываться как stdcall, тогда как pjsip декларирует свои callback'и как cdecl, это проблема! Путей решения у нее несколько.
Если бы у нас не было доступа к исходному коду pjsip - у нас была бы проблема, которую пришлось бы решать нетривиальным образом, изменяя IL код, сгенерированный для interop кода. Этот хак описан здесь.
UPD: Да, и пока я искал ссылку, нашлось более простое решение, поставляемое самой платформой .Net - UnmanagedFunctionPointer атрибут, с помощью которого можно пометить делегат и обозначить calling convention.
Последнее же решение, к которому прибегнул я, - пометил все нужные мне callback функции в pjsua __stdcall, благо их немного.
Итак, dll, экспортирующую функции высокоуровневого pjsua API я собрал, но даже для этого интерфейса (довольно скромного по сравнению с более низкоуровневыми) я бы писал interop код вручную недели две. Столько времени у меня нет, да это еще к тому же ужасно скучно, поэтому я нашел средство автоматической генерации interop кода - PInvoke Assistant. Достаточно лишь указать этой замечательной утилите всю необходимую информацию, и вуаля - 90% кода сгенерились автоматически. Надо сказать, что 10% недостающего кода эта_замечательная_утилита все-таки не осилила, но винить ее в этом я не намерен, т.к. код был действительно очень сложный для автоматического разбора. Вобщем 10%, написанные вручную, все-таки лучше, чем 100%.
UPD2: нашел еще одну утилиту для генерации interop кода: SWIG. Выглядит она намного мощнее той, которую я использовал, достаточно посмотреть на список поддерживаемых языков.
На этой стадии у меня уже был готов рабочий .Net интерфейс к pjsip, но вот только одна беда - в процедурном стиле, что впрочем не помешало мне написать тестовое консольное приложение, которое регистрировалось на SIP сервере.
2. Designing API.
Дизайн ООП обертки во многом продиктован структурой pjsua API (больше половины работы уже сделано за нас). pjsua логически и очень интуитивно разделяется на следующие группы:
- PJSUA-API Basic API Создание/инициализация, конфигурирование, логирование и пр.
- PJSUA-API Signaling Transport Управление SIP транспортами;
- PJSUA-API Accounts Management Управление аккаунтами;
- PJSUA-API Calls Management Управление звонками;
- PJSUA-API Buddy, Presence, and Instant Messaging Управление списками контактов, информацией о доступности и IM;
- PJSUA-API Media Manipulation Манипуляция медиа-данными;
- SIPUserAgent - центральная фигура и главная точка доступа к API, отвечающая за создание/конфигурирование клиента;
- Account Manager - интерфейс для управления аккаунтами (аналог - телефонный номер);
- Call Manager - интерфейс для управления звонками (прием/создание новых звонков, управление состоянием);
- Media Manager - интерфейс для управления медиа-параметрами системы (кодеки, звуковые устройства, конференция);
- Conference Bridge - жалкое отражение мощного медиа-микшера pjmedia;
Поскольку большинство объектов являются обертками над нативными структурами, выделяющимися в неуправляемой памяти, то нам нужен механизм управления освобождением последних. Такой механизм в .Net изобретен давно и называется он Disposable pattern. Очень кстати полезно помнить про этот паттерн, так как он .Net specific, а про такие MS любит спрашивать на собеседовании. Фундамент для Disposable pattern - базовый класс Resource, с одной лишь разницей, что он не реализует IDisposable, т.к. я не хочу отдавать контроль над временем жизни всех объектов, потому что это может нарушить работу системы. Например: нельзя дать пользователю этого API освободить ресурсы VoIPTransport'а во время звонка, или даже пока в системе есть зарегистрированные аккаунты. Следить же за всеми зависимостями просто невозможно, и вообще это есть нарушение всех принципов хорошего дизайна. Поэтому в Resource вместо публичной реализации IDisposable есть internal метод InternalDispose. В случае, когда время жизни объекта может управляться извне, то класс наследник Resource явно реализует интерфейс IDisposable.
Одна из основ дизайна - инициализируемые объекты. В pjsip прежде чем что-то использовать, это что-то сначала надо сконфигурировать и проинициализировать. Причем некоторые объекты поддерживают повторную реинициализацию, а некоторые - нет. О проблемах и решениях инициализации объектов я писал в предыдущей заметке, как раз в то время, когда создавался дизайн всей системы. Каждый инициализируемый класс, проводит валидацию установленных параметров по окончании сессии инициализации.
SIPUserAgent (UA) - центральная точка доступа к API. UA - инициализируемый объект и прежде чем вы получите доступ к различным частям системы, его необходимо... правильно! проинициализировать. По окончании сессии UA проверит все введенные данные на валидность и, если все в порядке, создаст необходимые структуры pjsua. UA поддерживает конфигурирование в config файле, но по умолчанию не использует его, хотя и имеет для этого явный интерфейс - метод ReadConfiguration(), который может быть вызван только во время сессии инициализации. Имеется и зеркальный метод WriteConfiguration(), который следует вызвать до того, как UA будет уничтожен. Кстати, UA это пример объекта, временем жизни которого можно управлять пользователям API, поэтому он реализует интерфейс IDisposable и как дополнение, для большей наглядности метод Destroy(), который по смыслу ничем не отличается от Dispose().
SIPUserAgent, AccountManager, CallManager, MediaManager - singleton'ы и они существуют всегда в единичном экземпляре. Это означает, что в приложении вы не сможете создать больше одного SIPUserAgent'а, да оно и не требуется, т.к. в системе есть понятие аккаунтов.
Transport интерфейс pjsua инкапсулирован в классе VoIPTransport.
Это пожалуй самая маленькая часть системы, предоставляющая интерфейс для создания и конфигурирования конкретного типа SIP транспорта (UDP, TCP или TLS). В UA поумолчанию создается UDPTransport для SIP, который может быть заменен либо при загрузке конфигурации, либо явно одним из наследников VoIPTransport во время сессии инициаллизации. К сожалению в текущем релизе pjsip поддерживает только UDP транспорт для RTP, но в дальнейших планах стоит TCP транспорт для RTP, что авторы сопровождают пометкой "анекдот", им виднее. Поэтому на всякий случай VoIPTransport, а не SIPTransport.
Многие объекты системы имеют различные состояния, которые прекрасно описываются state machine. Состояния эти передаются через callback'и из pjsip и обрабатываются соответствующими менеджерами, так за обработку callback'ов сессии регистрации аккаунта отвечает AccountManager, который затем сообщает state machine, что ее состояние изменилось. Поначалу state machines были частью сущности, состояние которой они описывали, но при более детальном взгляде оказалось, что объект (например звонок [Call] имеет Invite-сессию и медиа-сессию) может иметь несколько никак не связанных состояний, которые лучше даже назвать сессиями. Поэтому для моделирования этих сессий я написал малюсенький набор классов, состоящий из StateMachine - базового класса для сессии; и AbstractState - базового класса для состояния. Классы состояний управляют логикой обработки событий (этакий workflow), а классы сессий просто хранят атрибуты состояния. Я даже рассматривал возможность использования WF движка для моделирования сессий, но понял, что это был бы overkill для задачи такого масштаба.
Accounts
Тут все просто - пользователь создает аккаунт, инициализирует, передает AccountManager'у, который позаботится о его дальнейшей судьбе. В принципе уже здесь можно выкинуть ссылку на аккаунт, т.к. он автоматически попадает в коллекцию аккаунтов AccountManager'ов, из которой будет удален только когда будет вызван метод AccountManager'а UnregisterAccount(account).
AccountManager при старте системы создает внутренний локальный аккаунт, так что система сразу готова к приему и отправке звонков.
У аккаунта есть сессия регистрации, управляющая его состоянием. В принципе число возможных состояний аккаунта равно числу кодов статуса SIP протокола (около 100). Поэтому, чтобы не плодить кучу классов, описывающих состояния, в которые аккаунт неизвестно может ли перейти вообще, я описал лишь те состояния, в которых при тестировании аккаунт побывал однозначно (registering, timedout, registered), а для всех остальных написал общий класс UnknownStatusState, который, получая код статуса, пишет в Trace строку с описанием этого статуса.
Об изменениях в состоянии и Account и AccountManager информируют пользователей генерируя соответствующие события.
В классе Account великое множество свойств, из которых можно почерпнуть информацию о текущем состоянии аккаунта.
Calls
Здесь, конечно, дела обстоят посложнее, но не для пользователя системы, разумеется. С его точки зрения все также просто: чтобы позвонить куда-то нужно лишь вызвать метод MakeCall() CallManager'а, указав последнему куда позвонить и с какого аккаунта, чтобы принять звонок - надо подписаться на событие IncomingCall и вызвать метод Answer() переданного звонка, и все. Звонок также будет помещен в коллекцию звонков, и дальнейшей его судьбой будет управлять CallManager, ну и конечно же пользователь может явно закончить звонок, вызвав Call.Hangup(). Под капотом же все намного сложнее.
Начнем с того, что у звонка две сессии: Invite сессия, управляемая SIP протоколом, и медиа сессия, управляемая RTP протоколом.
С Invite сессией все более менее понятно - все через нее проходят каждый раз как набирают номер на мобильном и ждут соединения, а вот медиа-сессия более интересная тема. Дело в том, что звонок может иметь более одного слушателя, равно как и вещателя, т.е. быть частью конференции. Но здесь возникает небольшое расхождение с плоской иерархией классов состояний, описывающих эту сессию, т.к. звонок может быть активным и не участвовать в конференции, или участвовать, но не может быть неактивным и участвовать в конференции, т.к. это абсурд. Для описания этой ситуации я применил Decorator - ConferenceMediaStateDecorator, оборачивающий ActiveMediaState.
Между аккаунтом и звонками есть зависимость один-ко-многим, т.е. несколько звонков могут использовать один и тот же аккаунт. Следовательнго система должна каким-то образом следить за этими зависимостями. Можно создать множество и хранить в нем ассоциации, а можно воспользоваться механизмом подсчета ссылок, как это было сделано в COM. Именно этой техникой в сочетании с описанным приемом smart-pointer для .Net я и воспользовался для реализации это й задачи в loosely-coupled манере.
Каждый звонок при создании получает ссылку на аккаунт, с которым он ассоциирован. Он не хранит ее, а блокирует аккаунт (его невозможно будет удалить) вызовом метода Lock(), который возвращает IDisposable (smart-pointer), который при создании увеличивает число ссылок на аккаунт, а при явном уничтожении (Dispose) уменьшает. Этот smart-pointer освобождается при очистке ресурсов звонка.
Media
Медиа менеджер позволяет установить устройства воспроизведения и захвата звука, посмотреть коллекцию кодеков и посмотреть состояние конференц-моста.
Конференц-мост - это часть системы, выполняющая роль микшера медиа потоков. Звонки, подключающиеся к конференции, соединяются со всеми узлами двунаправленного графа, уже участвующих в конференции звонков. В системе может быть только одна активная конференция.
Подключение к конференции может происходить как автоматически - если установлен флаг UA AutoConference (тогда ActiveMediaState будет автоматически оборачиваться ConferenceStateDecorator'ом, который и подключает звонок к конференц мосту), либо явно при помощи метода Call.ConnectToConference() (в этом случае сначала будет произведена проверка активна ли медиа сессия и, если да, то ActiveMedaiState будет обернут ConferenceStateDecorator'ом с тем же результатом). За подключение звонков между собой отвечает ConferenceBridge.
3. Testing API.
Сейчас скажу крамолу - очень важно тестировать код! Поэтому в процессе разработки постоянно писалось тестовое консольное приложение. Оно позволило вычистить API, найти частично ошибки. Далее я решил залезть в чужую шкуру - стать пользователем своей же библиотеки и написать GUI приложение. И в процессе этой работы выловил еще несколько проблем. Например при уничтожении UA возникали racing conditions, связанные с отсутствием синхронизации между процессом уничтожения всех звонков и очисткой аккаунтов, или ошибки связанные с публикацией событий не в UI потоке, т.к. pjsip обрабатывает различные транзакции в различных потоках, то для упрощения клиентского кода, все менеджеры публикуют события через SyncronizationContext, буде таковой существует. Их как правило устанавливают GUI framework'и WinForms или WPF.
Conclusion
Возможно вы заметили, что я покрыл не все части pjsua API и не все возможности уже покрытых групп реализованы - это потому что моя обертка все еще в разработке!
Надеюсь чтение про мои страдания было интересным и поучительным.
PS: Код будет выложен позже.
Всем удачи!