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