戰(zhàn):從舊式@寫(xiě)法到$sel()的遷移與用法)
去年我接手一個(gè)從OpenSER時(shí)代一路遷過(guò)來(lái)的 Kamailio 路由腳本打開(kāi)配置文件第一眼就愣住了滿屏都是to.uri.host、from.uri.host這類(lèi)以 開(kāi)頭的寫(xiě)法。新版 Kamailio 雖然還認(rèn)一部分這種舊式 select但官方文檔的主推寫(xiě)法已經(jīng)變成了$sel()而且隨著模塊迭代舊寫(xiě)法在升級(jí)后經(jīng)常出現(xiàn)“值取不到”的詭異問(wèn)題。那段時(shí)間我一邊改腳本一邊查手冊(cè)最后把 Kamailio 的 select 框架從頭到尾理了一遍才發(fā)現(xiàn)這套東西比我想象中有用得多也坑得多。這篇內(nèi)容就是我當(dāng)時(shí)梳理和實(shí)測(cè)的記錄。如果你正在維護(hù)老 SIP 路由腳本或者想在 Kamailio 配置里更規(guī)范地讀取 SIP 消息內(nèi)部字段這篇文章應(yīng)該能幫你省下不少翻文檔的時(shí)間。我會(huì)從 select 框架要解決的問(wèn)題講起再給一份可以直接抄作業(yè)的中繼分流配置最后聊聊幾個(gè)我差點(diǎn)被繞進(jìn)去的坑。1. 從一段老舊路由腳本說(shuō)起select 框架究竟解決了什么問(wèn)題Kamailio 本身是 C 語(yǔ)言寫(xiě)的 SIP 服務(wù)器它的核心能力是把 SIP 請(qǐng)求解析成內(nèi)存里的結(jié)構(gòu)化數(shù)據(jù)。但配置腳本要訪問(wèn)這些內(nèi)部數(shù)據(jù)時(shí)不能直接用 C 結(jié)構(gòu)體只能通過(guò)偽變量、函數(shù)、字符串變換這類(lèi)“腳本層接口”。select 框架就是其中一種接口而且是非常特殊的一種。1.1 老式 select 與當(dāng)前的主流寫(xiě)法在 OpenSER 時(shí)代開(kāi)發(fā)者設(shè)計(jì)了一套以開(kāi)頭的 select 語(yǔ)法用來(lái)直接點(diǎn)取 SIP 消息里的字段。比如to.uri.host表示“從 To 頭的 URI 里取出主機(jī)名部分”。這種語(yǔ)法在當(dāng)年確實(shí)好用SIP 消息里的 Request-URI、From/To 頭、Contact 頭、消息體等都能用類(lèi)似點(diǎn)路徑的方式訪問(wèn)。但后來(lái) Kamailio 逐步把腳本層的偽變量體系統(tǒng)一到$前綴select 也有了新的外殼$sel(selector)。比如$sel(uri.host)就是取當(dāng)前請(qǐng)求 URI 的主機(jī)名功能和舊寫(xiě)法里的ru.host這類(lèi)用法一脈相承但整體語(yǔ)法更統(tǒng)一也能直接嵌入字符串、參與、~這類(lèi)比較運(yùn)算。我見(jiàn)過(guò)不少?gòu)?OpenSER 遷過(guò)來(lái)的配置還在用老式寫(xiě)法。不是說(shuō)完全跑不了而是維護(hù)起來(lái)很別扭文檔里大量示例都換成了$sel()社區(qū)回答也默認(rèn)新語(yǔ)法老配置一旦遇到模塊版本差異很難判斷到底是 select 本身失效還是寫(xiě)法不兼容。所以我的建議很直接碰到老腳本盡早統(tǒng)一改成$sel()或?qū)?yīng)的偽變量。1.2 為什么不能只靠偽變量可能你會(huì)問(wèn)Kamailio 不是有$ru、$rd、$hdr()這些偽變量嗎為什么還需要 select偽變量的確能覆蓋 80% 的場(chǎng)景但它有幾個(gè)短板。第一偽變量大多是“整塊”讀取比如$ru是整個(gè) Request-URI 字符串如果你只想取其中的 user 部分或 host 部分就得配合字符串變換或者正則去摳。第二偽變量的可寫(xiě)性很強(qiáng)這是優(yōu)點(diǎn)也是負(fù)擔(dān)因?yàn)橛行﹫?chǎng)景你只是想做一次只讀判斷并不希望腳本里有任何副作用。第三SIP 消息里的一些嵌套字段例如 URI 參數(shù)、消息體、某個(gè)頭字段的內(nèi)部結(jié)構(gòu)用普通偽變量表達(dá)起來(lái)要么很繞要么根本找不到對(duì)應(yīng)變量。select 框架的定位就是解決這些問(wèn)題的它提供一組“只讀”的字段路徑把消息解析結(jié)果按需暴露出來(lái)。調(diào)用時(shí)你只需要寫(xiě)清楚路徑框架負(fù)責(zé)定位到具體的struct sip_msg子結(jié)構(gòu)把結(jié)果作為字符串返回。這個(gè)設(shè)計(jì)思路有點(diǎn)像把 SIP 消息當(dāng)成一棵樹(shù)select 就是在樹(shù)上按路徑摘葉子。2. 新框架怎么工作$sel()調(diào)用方式與常用 selector搞清楚設(shè)計(jì)動(dòng)機(jī)之后就得看具體怎么用。Kamailio 的 select 框架在配置腳本里統(tǒng)一通過(guò)$sel()這個(gè)偽變量來(lái)執(zhí)行括號(hào)里填的是 selector 的 id。id 用點(diǎn)號(hào)分層比如uri.user表示“Request-URI 的 user 字段”msg.body表示“整個(gè)消息體”。2.1 基礎(chǔ)語(yǔ)法與求值規(guī)律最簡(jiǎn)單的用法就是在if條件里直接比較if ($sel(uri.host) pbx.example.net) { # 命中目標(biāo)主機(jī) }這里$sel(uri.host)會(huì)在運(yùn)行時(shí)對(duì)當(dāng)前 SIP 消息執(zhí)行求值返回一個(gè)字符串。取不到有效值時(shí)返回空很多情況下你可以用 $null來(lái)判斷是否存在。除了直接比較也可以把 select 的結(jié)果先存到變量里再做后續(xù)邏輯。我個(gè)人更推薦這種用法原因后面會(huì)講主要是性能和可讀性的平衡$var(ru_user) $sel(uri.user); $var(ru_host) $sel(uri.host); xlog(L_INFO, user[$var(ru_user)] host[$var(ru_host)]\n);每次執(zhí)行$sel()都會(huì)重新從當(dāng)前消息解析結(jié)構(gòu)里取一次值不是全局緩存的。所以同一個(gè)消息在路由過(guò)程中調(diào)用多次結(jié)果是一致的但開(kāi)銷(xiāo)會(huì)累積。如果你在幾個(gè)地方都要用同一個(gè)字段最好先賦值給$var再?gòu)?fù)用。2.2 核心 selector 速查Kamailio 核心提供的 select id 不算多但都很常用。下面這幾個(gè)是我在路由腳本里高頻使用的selector作用典型返回uri請(qǐng)求 URI 整體sip:881234client-a.example.neturi.user請(qǐng)求 URI 中的用戶部分881234uri.host請(qǐng)求 URI 中的主機(jī)部分client-a.example.neturi.port請(qǐng)求 URI 中的端口沒(méi)有則為空5060或空uri.params請(qǐng)求 URI 中的參數(shù)部分transportudpmsg.bodySIP 消息的消息體原始字符串v0\r\n...舉個(gè)例子如果收到一個(gè) INVITE請(qǐng)求行是INVITE sip:881234client-a.example.net;transportudp SIP/2.0那么$sel(uri.user)返回881234$sel(uri.host)返回client-a.example.net$sel(uri.params)返回;transportudp或transportudp具體格式在不同版本里可能略有差異這幾個(gè) selector 的價(jià)值在于你不需要自己寫(xiě)正則去拆$ru字符串框架已經(jīng)幫你拆好了。尤其是帶參數(shù)的 URI用正則摳參數(shù)很容易踩邊界條件select 直接給字段就清爽很多。2.3 模塊也可以注冊(cè)自己的 selector“框架”這兩個(gè)字是有實(shí)際含義的。Kamailio 核心只實(shí)現(xiàn)了一部分 select id其它模塊可以在初始化時(shí)往 select 注冊(cè)表里掛自己的函數(shù)。這樣配置腳本就能用統(tǒng)一的$sel()語(yǔ)法訪問(wèn)模塊內(nèi)部解析出來(lái)的擴(kuò)展數(shù)據(jù)。這意味著你在一個(gè)發(fā)行版里看到的 select id換到另一個(gè)精簡(jiǎn)編譯版本可能就沒(méi)有了。所以遇到不認(rèn)識(shí)的 selector第一反應(yīng)應(yīng)該是去查對(duì)應(yīng)模塊的文檔而不是懷疑語(yǔ)法寫(xiě)錯(cuò)。特別提醒一句select 的 id 并不是越多越好核心模塊提供的 id 在所有版本里相對(duì)穩(wěn)定第三方模塊的 id 則可能隨版本變化升級(jí)時(shí)尤其要注意。3. 完整實(shí)例按 URI 字段做雙中繼分流理論講再多不如一份能跑起來(lái)的配置。下面這個(gè)例子是我在實(shí)際項(xiàng)目中簡(jiǎn)化出來(lái)的場(chǎng)景用來(lái)演示 select 框架在路由決策里的完整使用鏈路。3.1 需求拆解假設(shè)你是企業(yè) SIP 中繼網(wǎng)關(guān)有兩個(gè)客戶域通過(guò)你的網(wǎng)關(guān)對(duì)外呼叫客戶域用戶號(hào)段出局中繼client-a.example.net以88開(kāi)頭10.10.1.20:5060client-b.example.net以99開(kāi)頭10.10.2.20:5060其它來(lái)源的 INVITE 一律拒絕。這個(gè)需求的關(guān)鍵點(diǎn)就是“從 Request-URI 里拆出 host 和 user再做兩級(jí)匹配”。用 select 來(lái)做是再合適不過(guò)的。3.2 可運(yùn)行的 cfg 片段下面是一段精簡(jiǎn)但完整的路由邏輯依賴tm和sl兩個(gè)模塊request_route { # 非 INVITE 直接走默認(rèn)中繼 if ($rm ! INVITE) { route(RELAY); exit; } # 用 select 把關(guān)鍵字段一次性取出來(lái) $var(ru_user) $sel(uri.user); $var(ru_host) $sel(uri.host); $var(ru_params) $sel(uri.params); xlog(L_INFO, select: user[$var(ru_user)] host[$var(ru_host)] params[$var(ru_params)]\n); # 按客戶域和號(hào)段分流 if ($var(ru_host) client-a.example.net) { if ($var(ru_user) ~ ^88) { route(TO_TRUNK_A); exit; } } else if ($var(ru_host) client-b.example.net) { if ($var(ru_user) ~ ^99) { route(TO_TRUNK_B); exit; } } sl_send_reply(404, Not Found); exit; } route[TO_TRUNK_A] { $du sip:10.10.1.20:5060; route(RELAY); exit; } route[TO_TRUNK_B] { $du sip:10.10.2.20:5060; route(RELAY); exit; } route[RELAY] { if (!t_relay()) { sl_reply_error(); } exit; }這段配置的核心在于先用$sel(uri.user)、$sel(uri.host)、$sel(uri.params)把字段抽出來(lái)后面所有判斷都基于變量腳本邏輯一下子清晰了很多。如果你用的是比較老的 Kamailio 分支sl_send_reply可能叫send_reply看一眼模塊文檔就知道。3.3 怎么驗(yàn)證 select 真的取對(duì)了值配置寫(xiě)完先別急著上線第一步先做語(yǔ)法檢查kamailio -c語(yǔ)法檢查只能保證格式?jīng)]錯(cuò)不能保證 select 取到了你期望的值。我習(xí)慣的做法是先啟動(dòng) Kamailio然后用nc發(fā)一個(gè) UDP 的 INVITE 測(cè)試消息看日志里 xlog 的輸出。測(cè)試消息長(zhǎng)這樣INVITE sip:881234client-a.example.net;transportudp SIP/2.0 Via: SIP/2.0/UDP 192.168.1.10:5060;branchz9hG4bK-test1 Max-Forwards: 70 From: sip:1001192.168.1.10;tag1001 To: sip:881234client-a.example.net Call-ID: test-select-001 CSeq: 1 INVITE Contact: sip:1001192.168.1.10 Content-Length: 0保存成文件后用nc發(fā)過(guò)去nc -u 127.0.0.1 5060 test_invite.txt正常的話Kamailio 日志里會(huì)打出這樣一行select: user[881234] host[client-a.example.net] params[;transportudp]如果 host 或 user 取出來(lái)是空的說(shuō)明你的 selector 在當(dāng)前版本里不存在或者消息本身格式有問(wèn)題。這一步能幫你把“select 用錯(cuò)了”和“路由邏輯錯(cuò)了”快速區(qū)分開(kāi)。4. 別把 select 當(dāng)萬(wàn)能鑰匙與偽變量、字符串變換、模塊化解析的取舍select 框架好用但它不是萬(wàn)能的。我在項(xiàng)目里經(jīng)??吹接腥税阉凶侄翁崛《级训?select 上結(jié)果代碼既啰嗦又不好維護(hù)。說(shuō)到底select 只是工具之一選型要看場(chǎng)景。4.1 select 與普通偽變量的對(duì)比先看一張對(duì)比表維度select$sel()普通偽變量$ru、$rd、$hdr()等讀寫(xiě)性只讀大多可讀可寫(xiě)表達(dá)力點(diǎn)號(hào)路徑清晰適合拆字段短但整塊讀取時(shí)需二次處理典型場(chǎng)景從 URI/消息體中提取子串做判斷快速讀取、修改路由目標(biāo)版本差異第三方模塊 id 可能變化核心偽變量相對(duì)穩(wěn)定調(diào)試方式可直接賦給變量打印同樣可打印這里面最容易被忽視的是“可寫(xiě)性”的差異。select 是只讀的它只是從消息結(jié)構(gòu)里取字符串給你看不能改。如果你想把請(qǐng)求 URI 的 host 改成新的值正確做法是用$rd這類(lèi)可寫(xiě)偽變量# 錯(cuò)誤示范select 不能賦值 $sel(uri.host) new.example.net; # 正確做法需要改 host 時(shí)用可寫(xiě)偽變量 $rd new.example.net;雖然這個(gè)例子很基礎(chǔ)但我在代碼評(píng)審里見(jiàn)過(guò)不止一次有人想“省一步”直接對(duì) select 賦值結(jié)果配置加載失敗。4.2 select、字符串變換與正則怎么分工Kamailio 的字符串變換可以做到類(lèi)似 select 的提取效果比如對(duì)$ru做各種變換取出部分字段。但我的原則是能靠字段路徑說(shuō)清楚的事就不要引入變換鏈。變換適合在原有值的基礎(chǔ)上做格式化比如把 user 部分取出來(lái)后去掉前綴、或把某個(gè)偽變量做大小寫(xiě)轉(zhuǎn)換而 select 的定位更偏“結(jié)構(gòu)化讀取”。正則也是一樣。用~直接從$sel(uri.params)里判斷是否包含transportudp是沒(méi)問(wèn)題的if ($sel(uri.params) ~ transportudp) { # UDP 傳輸 }但如果整個(gè)路由邏輯里全是正則去摳 URI 字段代碼的可讀性會(huì)很差出問(wèn)題后也不好查。select 的價(jià)值就在于此把字段邊界交給框架而不是交給你自己寫(xiě)的正則表達(dá)式。4.3 復(fù)雜結(jié)構(gòu)數(shù)據(jù)要交給專(zhuān)業(yè)模塊select 框架能拿到msg.body但拿到的是原始字符串。如果 body 是 SDP、JSON、XML 這類(lèi)有復(fù)雜結(jié)構(gòu)的數(shù)據(jù)我不建議用 select 加正則去解析。SDP 內(nèi)容判斷用sdpops模塊的相關(guān)函數(shù)JSON 解析用json或jansson模塊數(shù)據(jù)庫(kù)查詢結(jié)果有專(zhuān)門(mén)的變換和函數(shù)。簡(jiǎn)單說(shuō)select 的邊界在“SIP 消息的原始字段”而不是“字段內(nèi)容的深加工”。你可以在調(diào)試階段用 select 快速看一眼 body 里有沒(méi)有某個(gè)關(guān)鍵字生產(chǎn)環(huán)境里涉及到媒體協(xié)商、數(shù)據(jù)提取還是要走專(zhuān)業(yè)模塊。5. 用 select 框架時(shí)踩過(guò)的幾個(gè)隱蔽坑這部分是我最想寫(xiě)的。select 框架本身不復(fù)雜但實(shí)際使用中有一堆“文檔里沒(méi)有但線上會(huì)炸”的細(xì)節(jié)。5.1 selector 拼錯(cuò)了啟動(dòng)不報(bào)錯(cuò)運(yùn)行時(shí)靜默返回空這個(gè)坑我印象最深。當(dāng)時(shí)我寫(xiě)了一個(gè)$sel(uri.hostname)去判斷主機(jī)名kamailio -c語(yǔ)法檢查通過(guò)了但路由就是不命中。查了半天才發(fā)現(xiàn)selector id 是注冊(cè)表驅(qū)動(dòng)的未知 id 在運(yùn)行時(shí)不會(huì)直接報(bào)錯(cuò)而是求值成空字符串。if ($sel(uri.hostname) example.net)永遠(yuǎn)為假。排查方法其實(shí)很簡(jiǎn)單把 select 結(jié)果打出來(lái)看。$var(dbg) $sel(uri.hostname); xlog(L_INFO, debug select: [$var(dbg)]\n);日志里如果打出空括號(hào)基本可以確定 selector id 不對(duì)。所以拿到陌生配置時(shí)不要盲目信任里面寫(xiě)的 id先去當(dāng)前版本的文檔里核對(duì)一遍模塊提供的 select 列表。5.2 大字段反復(fù)取性能容易出問(wèn)題msg.body這種字段可能很大一個(gè)帶 SDP 的 INVITE body 動(dòng)輒幾百上千字節(jié)。如果你在路由腳本里重復(fù)寫(xiě)$sel(msg.body)每次執(zhí)行都是重新走一遍查找和字符串返回相當(dāng)于同一份大字符串被反復(fù)翻出來(lái)。Kamailio 的路由腳本本身不適合做重邏輯這種浪費(fèi)更不值得。我的習(xí)慣是如果一個(gè)字段要在多處使用第一時(shí)間賦值給$var后面都用變量$var(body) $sel(msg.body); if ($var(body) ! $null) { # 業(yè)務(wù)判斷 }這樣既保證結(jié)果一致也避免重復(fù)求值。尤其是處理 body 這種大字段時(shí)緩存變量的好處立竿見(jiàn)影。5.3 只讀語(yǔ)義引發(fā)的心態(tài)崩潰select 是只讀的這從設(shè)計(jì)上是優(yōu)點(diǎn)但很多人一開(kāi)始不知道。有位同事遷腳本時(shí)想當(dāng)然地寫(xiě)了類(lèi)似$sel(uri.host) xxx的代碼想著“既然能讀到應(yīng)該也能寫(xiě)回去”結(jié)果配置反復(fù)加載不過(guò)還以為是語(yǔ)法問(wèn)題。最后改成用$rd賦值才解決。這點(diǎn)值得反復(fù)強(qiáng)調(diào)select 只負(fù)責(zé)“讀”和“判斷”需要“改”的時(shí)候換偽變量需要“深層解析”的時(shí)候換模塊函數(shù)。把工具邊界理清楚腳本維護(hù)成本會(huì)低很多。5.4 老腳本遷移別搞“一刀切”從舊寫(xiě)法遷移到$sel()或偽變量時(shí)最忌諱一次性全局替換。舊寫(xiě)法里有些 selector 在新版里語(yǔ)義可能有細(xì)微差別比如對(duì)畸形 URI 的處理、對(duì)空值的返回方式。我建議分兩步走第一步先加日志把每個(gè)舊 select 的真實(shí)值和預(yù)期值打印出來(lái)確認(rèn)當(dāng)前版本解析結(jié)果是什么。第二步逐行替換并回歸測(cè)試。舉個(gè)例子舊寫(xiě)法里to.uri.host這種含義在新配置里我會(huì)優(yōu)先用$tH這類(lèi)專(zhuān)門(mén)的偽變量而不是硬套$sel()。偽變量更短語(yǔ)義也更明確。老腳本遷移的完整流程不適合在一篇文章里鋪開(kāi)但記住一個(gè)原則先看清真實(shí)求值結(jié)果再動(dòng)手改代碼。離開(kāi)日志做重構(gòu)跟蒙眼開(kāi)車(chē)差不多。最后再分享一個(gè)習(xí)慣我現(xiàn)在寫(xiě) Kamailio 路由腳本時(shí)凡是涉及 Request-URI、From/To 頭、消息體這類(lèi)“結(jié)構(gòu)化字段提取”第一反應(yīng)就是用 select 把字段抽出來(lái)交給$var再走后續(xù)分支凡是涉及修改消息目標(biāo)立刻切回可寫(xiě)偽變量。這樣做了一段時(shí)間之后腳本里幾乎見(jiàn)不到大段正則摳字段的代碼了排障時(shí)每條 xlog 都能直接看出 select 取到了什么思路清楚很多。