Здравствуйте, меня зовут Валентин, и я задолбался. Нет-нет, вы всё ещё на Хабре.
Все технологии телефонии ужасны.
Большинство технологий разработки IETF ужасны. Может, не ужасны-ужасны, как ISO…
Когда они смешиваются… ну вы в курсе. Или ещё нет? Получается SIP.
Это пост ворчания, техническая суть которого может быть полезна паре сотен человек. Но, to grumble is human.
Начнём с синтаксиса
HTTP заголовок видели все. Ну, большинство. Кто не видел — может посмотреть на заголовок почтового письма (email). Принципы взяты оттуда, НО...
Я буду брать примеры из стандартных RFC, чтобы не влияла местная специфика. Типичное сообщение выглядит в таком стиле:
INVITE sip:bob@biloxi.example.com SIP/2.0
Via: SIP/2.0/TCP client.atlanta.example.com:5060;branch=z9hG4bK74bf9
Max-Forwards: 70
From: Alice <sip:alice@atlanta.example.com>;tag=9fxced76sl
To: Bob <sip:bob@biloxi.example.com>
Call-ID: 3848276298220188511@atlanta.example.com
CSeq: 1 INVITE
Contact: <sip:alice@client.atlanta.example.com;transport=tcp>
Content-Type: application/sdp
Content-Length: 151
v=0
o=alice 2890844526 2890844526 IN IP4 client.atlanta.example.com
s=-
c=IN IP4 192.0.2.101
a=sendrecv
t=0 0
m=audio 49172 RTP/AVP 0
a=rtpmap:0 PCMU/8000
Alice инициирует звонок к Bob (полные адреса указаны в виде user@domain), предлагая аудио в кодеке PCMU (G.711u) и сообщая свои адреса (хост: порт) для сигнализации и для медии.
Пойдём вглубь…
Параметры, параметры и параметры
У нас есть address parameters, URI parameters и user parameters. Не то чтобы это был необходимый набор для работы, но если начал, становится трудно остановиться… Все три отделяются точками с запятой, ограничения на синтаксис у всех немного разные. Смотрите не перепутайте. Кстати, чем отличается адрес от URI, тоже не каждому сразу получится объяснить. Адрес (определение формально не встречается в спеках, но постоянно используется на практике) — это содержимое всяких From/To/Contact/Route (один элемент, где их несколько), URI с параметрами и (возможно) именем… Ну поехали:
To: sip:bob@example.com
тут всё просто — схема, домен и юзер.
To: sip:bob@example.com;xyz=123
xyz=123 — параметр адреса. Но: если URI должен присутствовать сам по себе, как в первой строке запроса:
INVITE sip:bob@example.com;xyz=123
то xyz=123 будет уже параметром URI.
To: <sip:bob@example.com>;xyz=123
To: "Bob" <sip:bob@example.com>;xyz=123
xyz=123 — параметр адреса.
To: <sip:bob@example.com;xyz=123>;xyz=456
xyz=123 — параметр URI.
xyz=456 — параметр адреса.
To: sip:bob;rn=alice@example.com;xyz=987
Так нельзя. Странно, да? Хотя если URI там, где нужен именно URI (в request line), то можно:
INVITE sip:bob;rn=alice@example.com;xyz=987 SIP/2.0
Надо заключить в угловые скобки:
To: <sip:bob;rn=alice@example.com;xyz=987>;xyz=123
rn=alice — параметр юзера.
xyz=987 — параметр URI.
xyz=123 — параметр адреса.
Кстати, во всех допускаются символы за пределами очевидного набора типа [A-Za-z0-9] и ещё некоторого набора неожиданно допущенных, но по-разному. В user и в URI надо квотить недопустимое их кодами через %XX, а в адресе — через quoted string, и никак иначе. Поэтому параметр со значением a%\b будет передан:
To: <sip:bob;xyz=a%25%5Cb@example.com;xyz=a%25%5Cb>;xyz="a%\\b"
В спеке на синтаксис, обобщение для параметра URI зовётся other-param, а параметра адреса — generic-param. Смотрите не перепутайте, правила для них заметно разные. Кстати, user parameters это другая спека (точнее, произведение двух — RFC4694 + RFC3398).
Что может быть значением параметра URI? Что угодно, только надо его единообразно заквотить (через %XX), дальше это набор октетов (UTF-8, если в SIP). Примеры были выше.
Что может быть значением параметра адреса? token, quoted string или host. Кстати, token — это не token в смысле грамматического разбора. Это особый token, которого лично зовут token. Но при этом с маленькой буквы, потому что он самый скромный token.
To: <sip:bob@example.com>;xyz=127.0.0.1
127.0.0.1 это host или token? Допустимы оба. Должен ли я считать, что это host?
To: <sip:bob@example.com>;xyz=[fe80::1]
To: <sip:bob@example.com>;xyz="[fe80::1]"
Значения одинаковы. Но в первом случае это host подтипа IPv6reference,
во втором — quoted string. Если я прокси и передаю параметр через себя, я должен сохранить, чем он был — восстановление по содержимому не поможет.
Кстати, к предыдущему примеру:
To: <sip:bob@example.com>;xyz="127.0.0.1"
это не host и не token. Но если объекту типа address скажут назначить параметр со значением "127.0.0.1"
— какой тип выбрать? Ах ну да, такая установка уже должна указывать тип...
Вместе с тем, что если это token, то регистр букв не имеет значение, а если quoted-string, то имеет; а если host, то снова не имеет… — для адекватности одновременно обработки и сохранения исходного значения (про регистр подробнее ниже) я должен хранить:
1) Исходную форму как октетную или unicode строку — для точного воспроизведения;
2) Приведённую форму — для сравнения;
3) Признак, какого типа был параметр на входе (насколько это вообще восстановимо, не зная по названию параметра, чем он должен быть… хотя годится обобщение типа — любой host кроме IPv6reference может храниться как token… там ещё есть несколько подобных тонкостей).
Но в To допускается только один адрес. А в Contact — несколько. Есть некоторые другие поля заголовка, где тоже можно, но пока пусть например будет слонёнок Contact:
Contact: sip:alice@example.com, sip:bob@example.com
Да, можно.
Contact: <sip:alice@example.com>, sip:bob@example.com, <sip:mallory@example.com>
Тоже можно.
Contact: sip:alice,bob@example.com
Нельзя. URL с запятой должен быть заключён в <>. Распарситься не должно: после запятой нет схемы URL.
Contact: sip:alice, bob bob bob <bob@example.com>
Уже можно. Хотя это надо минимум три синяка от граблей, чтобы сразу видеть, что alice — уже не юзер, а домен.
Contact: sip:alice,bob@example.com, sip:mallory@example.com
Нельзя. URL с запятой внутри поля заголовка конструкции «адрес» должен быть заключён в <>.
Contact: <sip:alice,bob@example.com>, sip:mallory@example.com
Уже можно. При этом второй адрес заключать не обязательно.
Хорошо, а это что тогда?
Contact: sip:alice,sip:bob@example.com
Это два адреса в контактах: sip:alice (alice это хост, user отсутствует целиком) и sip:bob@example.com. Но:
Contact: <sip:alice,sip:bob@example.com>
Это один адрес с user=«alice,sip» и паролем bob. Да-да, запятая в user возможна и её можно не квотить.
Это я ещё не вспоминал, что впереди может быть display-name, а позади — адресные параметры:
Contact: sip user <sip:alice>, "The Bob" <sip:bob@example.com>;
foo="The Bob, Almighty and Superb <sip:bob>", <tel:alice;xyz=987>
И снова здравствуйте, меня зовут Валентин. («Здравствуй, Валентин» — ответил нестройный хор.) Я сделал реализацию разбора SIP регулярными выражениями, и нет, я не стыжусь этого. У меня нет проблем, которые нельзя так решить: в грамматике нет рекурсии (максимум — есть списки), и, к счастью, это единственное, что там хорошее. Если бы была рекурсия — разбор был бы ужасен-ужасен. А так, он просто ужасен:
>>> len(SipSyntax.re_addr)
2847
Нет, мне не страшно. Потому что эти выражения формируются из отдельных частей:
re_addr = "(?:%s|%s)%s?" % (re_name_addr, re_addr_spec_short, re_gen_params_wsc)
и на каждом этапе я могу проверить качество каждого выражения и соответствие правилам комбинирования. Кстати, некоторые мелочи, которые никогда за почти 20 лет существования продуктового стека не понадобились (типа возможности absoluteURI), тут не реализованы. С ними было бы ещё в полтора раза больше, наверно.
Но почему таки регулярные выражения? — спросит отдельный робкий голос. Ответ: а потому что Python.
Я сравниваю один и тот же разбор URI на Python через regexps, Java через regexps и Java через пропуск по классам символов. Сравнение эффективности на одной и той же железке (Ryzen5):
CPython + regexps - 14K/sec PyPy + regexps - 130K/sec Java + regexps - 270K/sec Java + symbol classes - 1800K/sec PyPy + symbol classes - 3K/sec
Всё потому, что в Python движок регулярных выражений — написан на C, а обходить его означает выходить на уровень интерпретатора, который страшно медленный. Предкомпилированные (на этапе загрузки модулей парсинга) выражения превращаются в аккуратные таблички в памяти — это хуже, чем выхлоп какого-нибудь re2c, но по ним бегает неплохо оптимизированный код матчинга. В Java вроде JIT, но движок регулярных выражений написан на самой Java, и JIT ему не очень помогает, когда логика состоит из сплошных переходов с лукапами по табличкам, которые этим JITʼом не перерабатываются. На чём-то напрямую компилируемом цифры могут быть ещё вкуснее… нет, я не буду впадать в этот флейм. PyPy заметно помогает, но есть порог, за которым кончается и его умение (а цифры говорят, что >90% накладных расходов CPython это работа со структурами Python, а не собственно управление памятью, парсинг и т.п.)
Что такое лукап по классам символов — это в таком духе:
while (pos < limit && (b = source[pos]) >= 0 &&
SipSyntax.userinfo_allowed[b] != 0)
{
++pos;
}
табличка userinfo_allowed тщательно выверена руками и подкреплена юнит-тестом.
(Меня удивила цифра PyPy, но, может, это я оказался такой криворукий. Но на CPython я даже не пытаюсь это делать… страашно. Ладно, может, попробуем, как сады зацветут.)
А ещё есть проблема '#'. По RFC, такое не разрешено:
INVITE sip:123#456@example.com SIP/2.0
From: <sip:123#789@example.com>; tag=from1
но не только лишь все (tm), начиная с Cisco, это игнорируют. Поэтому почти сколько существует наш продукт, почти столько и существует параметр получателя url_hash_safe, который говорит рисовать #, а не %23 (а %23 многие из них не понимают). Но некоторые наоборот, не понимают, если ставить '#'. Поэтому нужна регуляция по конкретному ремотному участнику — определять, кому таторы, а кому ляторы. Последний раз я подсказывал техподдержке включить этот параметр прошлой осенью. Наверно, эта проблема будет жить вечно. Ах да, '*' — ещё один частый символ в этих последовательностях — разрешён явно и проблемы не вызывает. Неаккуратненько-с.
В RFC3261 есть роскошное примечание:
The Contact, From, and To header fields contain a URI. If the URI contains a comma, question mark or semicolon, the URI MUST be enclosed in angle brackets (< and >). Any URI parameters are contained within these brackets. If the URI is not enclosed in angle brackets, any semicolon-delimited parameters are header-parameters, not URI parameters.
Спасибо, дорогая партия, за наше счастливое детство. У меня вопрос: а зачем вообще было разрешать вариант без угловых скобок? Ах да, забыл: в Route и Record-Route угловые скобки обязательны. В From, To, Contact… — нет. Картина Репина.
Почему не готовый движок парсинга?
Если упоминается Java, кто-то обязательно спросит — почему не ANTLR?
Я пробовал применить ANTLR. Нет, конечно, это можно. Забитый молотком шуруп держится лучше, чем вкрученный отвёрткой гвоздь. В linphone его применяют. Но уже там видны недочёты.
Ему нельзя снаружи управлять контекстом для лексического анализатора, а это критично. Есть несколько разных стартовых контекстов: как минимум: первая строка сообщения, URI, адрес, ряд специфических заголовков со своими правилами (Call-Id, Authorization...)
Можно было бы справиться с semantic rules «на месте», но:
- Каждый парсер стартует со своим лексером.
- Лексер не может получать указание по контексту от парсера, он должен определять это сам.
- Контекстов очень много.
Часто однозначного решения нет. Начало адреса — может присутствовать или схема с ':' (включать ли двоеточие в токен лексера?), или display-name как последовательность token (это не тот токен, это другой токен, который token в грамматике SIP). После схемы в SIP-URI — или хост, или username, и различить их можно только по тому, что раньше встретится после него (может, заметно после него) — '@', ';', '?' или EOI. Не так предсказал — откат назад на другую гипотезу.
Начальных контекстов — как минимум: начало адреса (одиночного или множественного поля заголовка типа From, To, Contact, Route, Record-Route...); начало URI (например, в первой строке SIP запроса). Есть ещё несколько специфических контекстов — для авторизационных заголовков, например — там целые URI могут находиться в кавычках. В результате, получается сплошное дублирование. Грамматики лексеров не могут включать другие грамматики лексеров (почему?) В результате чтобы избежать копипастинга надо генерировать грамматики отдельным макропроцессором. Ну спасибо...
Альтернативно, можно было бы на уровне лексера определить каждый символ, а выше — собирать их уже в грамматические конструкции. Исключение даётся только для quoted-string (и то за пределами авторизационных заголовков). Но производительность падает на порядки, реализация на Java становится тормознее реализации на Python+regexps.
Поэтому доступные реализации невозможно читать без ящика водки. Виски не подойдёт: он для добавления положительных эмоций, а не гашения отрицательных. Если бы не требование менять контексты лексера из самого лексера, было бы ещё подъёмно (LL(∞) превратился бы в цивилизованный PEG), но не как сейчас.
Вместо регулярных выражений можно использовать классы символов. И на компилируемом языке это быстрее (ускорение раз в 5 я получаю без оптимизационных ухищрений). Но не на Python: его движок регулярных выражений на C, а поиск по строке будет чудовищно дорог.
В целом, мир грамматики IETF выглядит сущностью, принципиально противоположной тому, что рекламируется, например, в Dragon Book. Возможно, для него нужна своя теория, которая будет заметно отличаться от знаменитых LL(k), LR(k) и прочих. Но я такого не видел.
И ложка мёда:
А знаете, что хорошо по сравнению с RFC822, например? Границы строк чётко определены. Кошмариков типа "\\\r\n"
, которое неверно разбирали 99% почтовых парсеров, уже нет. Поэтому я могу (и даже обязан) сначала опознать границы строк, по ним — полей заголовка, и тогда каждый подавать на разбор — уже по своему вкусу (а скорее всего вообще не подавать те, что не нужно).
За 17 лет (1982->1999) IETF кое-чему научился. Жаль, что это так медленно происходит.
Морфология
У нас в каждом сообщении нужно посылать From и To. На INVITE выше приходит ответ, например:
SIP/2.0 100 Trying Via: SIP/2.0/TCP client.atlanta.example.com:5060;branch=z9hG4bK74bf9 ;received=192.0.2.101 From: Alice <sip:alice@atlanta.example.com>;tag=9fxced76sl To: Bob <sip:bob@biloxi.example.com> Call-ID: 3848276298220188511@atlanta.example.com CSeq: 1 INVITE Contact: <sip:bob@client.biloxi.example.com;transport=tcp> Content-Length: 0
Реально они нужны только в одном виде сообщений — в начальном для установления диалога (или в отдельной транзакции). Дальше их можно тупо выкидывать, от этого ничего не поменяется: на них ничего не завязывается и никто не парсит, хотя пропускать их запрещено. Матчинг ответов на транзакции происходит по h_Via.branch. Матчинг ответов внутри диалога происходит по тегам диалога. И снова, смотрите не перепутайте. Я сам путаюсь, когда через несколько месяцев не-общения с этим всем возвращаюсь к тематике.
Так зачем же нужны эти From и To? А затем, что там теги.
From: Alice <sip:alice@atlanta.example.com>;tag=9fxced76sl
To: Bob <sip:bob@biloxi.example.com>;tag=skfhjskdkfsd
Ах да, я ж забыл упомянуть теги:
У нас есть call-id и есть два тега. Вместе они образуют полный идентификатор диалога (ассоциация между двумя конечными участниками). Сообщение внутри диалога (у него два тега) определяет модификацию установленного звонка, а вне диалога (только один тег) — новый звонок. (Я упростил всё, что можно, но для первого описания пойдёт.)
Call-id имеет особые, ни на что остальное в SIP не похожие, правила допуска и сравнения. Он регистрозависим (в отличие от тегов диалога), но об этом ниже. У него свой набор допустимых символов. Call-id неуникален, его совпадение у соседних звонков — норма и закономерность (при форкинге диалога, когда на один запрос отвечают несколько удалённых участников). В отличие от, теги диалога уникальны, требуется хотя бы 32 бита случайности при их генерации, больше — лучше. На самом деле после генерации тегов call-id вообще не нужен, у него роль… мнэээ… в рамках стандартных использований по RFC у него роли вообще нет, кроме как путаться под ногами, всё работало бы и без него. А вот для B2BUA, как у нас, call-id начинает выполнять особую функцию связи между звонками на разных сторонах одного B2BUA. Но B2BUA не определён ни одним RFC даже в ранге «informational»; это такое странное понятие, на которое ссылаются в нескольких RFC, но определено в лучшем случае неформально.
Так почему теги не в отдельных полях заголовка? Вы не найдёте обоснованного ответа на этот вопрос: это уже закон жизни, который тайна веков. Но зато ради них приходится полностью парсить целые From и To, с учётом всех тонкостей по содержанию (см. выше про адресные поля).
Cинтаксис возвращается: регистр букв
По непонятной прихоти call-id регистрозависим, а теги — нет. Точнее, «так в книге написано» (tm), что они регистронезависимы. Реально же:
1) Есть UA (user agents — железные или программные телефоны), которые воспринимают посылки в диалоге только если буквы в теге в том же регистре, что они посылают (если это Abc, то и надо прислать им Abc).
2) Есть UA, которые переделывают теги под себя, и если им прислать abc — они ответят ABC. Или наоборот, прислали ABC — а они отвечают abc.
И с обоими надо работать. Отказывать просто потому, что они нарушают… ну в основном нельзя. Потому что всё равно не исправят — большинство из них железные, и авторы прошивки просто не поймут, чего от них хотят. Не знаю, зачем производители железных UA нанимают таукитян… или сентинельцев? Сложно разобрать, но проверено, что планета у них какая-то другая.
Какой вариант работает в итоге? Тег надо хранить в двух вариантах: приведённый к конкретному регистру — для сравнения, и неприведённый — для посылки другому участнику. Где-то мы это уже слышали?
И то же оказалось… привет, история в Git… с transport параметром URI. Потому что мы добрые, и если чужой телефон или SBC настаивает, что должно быть transport=UDP, а не transport=udp, то легче согласиться, чем спорить. Это карма авторов чисто софтового продукта: легче адаптировать софт, чем даже прошивку железного аппарата (а тем более само железо).
Но это были цветочки, основной же вопрос — зачем вообще эта регистронезависимость?
Стандартные аргументы в пользу регистронезависимости: облегчение человекочитаемости. Это миф: её путают с человекоозвучиваемостью. Регистр не должен быть важен, например, в имени человека (хотя и тут могут быть тонкости). Но я не знаю, в каком случае с экрана будет читаться что-то в шрифтах, которые не различают регистр. И совсем не понимаю, почему и зачем различие регистра считается важнее, чем 100500 других различий.
Этот тезис можно обсудить в применении, например, к файловым системам — отдельно (почему такие FS различают «foo.docx», «foo.docx », «foо.docx» и «foo.docx» (кто увидит разницу?), но не различают «foo.docx» и «Foo.docx»?), и что будет в случае İı (см. Turkey test), но сейчас речь не о них, а о SIP на простом беспроблемном ASCII.
Вы можете написать:
<code> From: <sip:123@foobar>;tag=mamarama </code>
но вы можете также написать:
<code> fRoM : <sip:123@foobaR>;TaG=mamaRAMa </code>
Все воспринимающие должны понять это одинаково. Но чего этого стоит? Обратимся к коду одной из самых быстрых реализаций — Kamailio, в девичестве Sip Express Router.
/*! \brief macros from parse_hname2.c */
#define READ(val) \
(*(val + 0) + (*(val + 1) << 8) + (*(val + 2) << 16) + (*(val + 3) << 24))
#define LOWER_DWORD(d) ((d) | 0x20202020)
...
#define _cont_ 0x746e6f63
...
...
// например возьмём этот кусок
if ((LOWER_DWORD(READ(c)) == _cont_) &&
(LOWER_DWORD(READ(c+4)) == _ent__) &&
(LOWER_DWORD(READ(c+8)) == _type_)
) {
/* Content-Type HF found */
Ну, это хорошо работает на C. (Clang умудрился свернуть READ в просто 32-битное чтение из памяти. GCC этого не сумел. Ну не так страшно.) Уже на Java надо финтить, но хотя бы есть ByteBuffer. На интерпретаторах вроде Python, и на Java на строках, это превращается в чудовищную затрату или времени, или памяти, или обоих сразу.
Но и этот детект помогает только в очень ограниченной роли — быстрый поиск по первым четвёркам символов (а ещё есть альтернативные имена полей — f вместо from, i вместо call-id; пробелы до и после ':' в неограниченном количестве; и так далее). А как только мы выходим за пределы средств с прямым изменением в буферах… привет, копирование.
Продолжение следует…