去年我接手一个从OpenSER时代一路迁过来的 Kamailio 路由脚本,打开配置文件第一眼就愣住了:满屏都是@to.uri.host、@from.uri.host这类以 @ 开头的写法。新版 Kamailio 虽然还认一部分这种旧式 select,但官方文档的主推写法已经变成了$sel(),而且随着模块迭代,旧写法在升级后经常出现“值取不到”的诡异问题。那段时间我一边改脚本一边查手册,最后把 Kamailio 的 select 框架从头到尾理了一遍,才发现这套东西比我想象中有用得多,也坑得多。
这篇内容就是我当时梳理和实测的记录。如果你正在维护老 SIP 路由脚本,或者想在 Kamailio 配置里更规范地读取 SIP 消息内部字段,这篇文章应该能帮你省下不少翻文档的时间。我会从 select 框架要解决的问题讲起,再给一份可以直接抄作业的中继分流配置,最后聊聊几个我差点被绕进去的坑。
1. 从一段老旧路由脚本说起:select 框架究竟解决了什么问题
Kamailio 本身是 C 语言写的 SIP 服务器,它的核心能力是把 SIP 请求解析成内存里的结构化数据。但配置脚本要访问这些内部数据时,不能直接用 C 结构体,只能通过伪变量、函数、字符串变换这类“脚本层接口”。select 框架就是其中一种接口,而且是非常特殊的一种。
1.1 老式 select 与当前的主流写法
在 OpenSER 时代,开发者设计了一套以@开头的 select 语法,用来直接点取 SIP 消息里的字段。比如@to.uri.host表示“从 To 头的 URI 里取出主机名部分”。这种语法在当年确实好用,SIP 消息里的 Request-URI、From/To 头、Contact 头、消息体等都能用类似点路径的方式访问。
但后来 Kamailio 逐步把脚本层的伪变量体系统一到$前缀,select 也有了新的外壳:$sel(selector)。比如$sel(uri.host)就是取当前请求 URI 的主机名,功能和旧写法里的@ru.host这类用法一脉相承,但整体语法更统一,也能直接嵌入字符串、参与==、=~这类比较运算。
我见过不少从 OpenSER 迁过来的配置,还在用老式@写法。不是说完全跑不了,而是维护起来很别扭:文档里大量示例都换成了$sel(),社区回答也默认新语法,老配置一旦遇到模块版本差异,很难判断到底是 select 本身失效,还是写法不兼容。所以我的建议很直接:碰到老脚本,尽早统一改成$sel()或对应的伪变量。
1.2 为什么不能只靠伪变量
可能你会问:Kamailio 不是有$ru、$rd、$hdr()这些伪变量吗,为什么还需要 select?
伪变量的确能覆盖 80% 的场景,但它有几个短板。第一,伪变量大多是“整块”读取,比如$ru是整个 Request-URI 字符串,如果你只想取其中的 user 部分或 host 部分,就得配合字符串变换或者正则去抠。第二,伪变量的可写性很强,这是优点也是负担,因为有些场景你只是想做一次只读判断,并不希望脚本里有任何副作用。第三,SIP 消息里的一些嵌套字段,例如 URI 参数、消息体、某个头字段的内部结构,用普通伪变量表达起来要么很绕,要么根本找不到对应变量。
select 框架的定位就是解决这些问题的:它提供一组“只读”的字段路径,把消息解析结果按需暴露出来。调用时你只需要写清楚路径,框架负责定位到具体的struct sip_msg子结构,把结果作为字符串返回。这个设计思路有点像把 SIP 消息当成一棵树,select 就是在树上按路径摘叶子。
2. 新框架怎么工作:$sel()调用方式与常用 selector
搞清楚设计动机之后,就得看具体怎么用。Kamailio 的 select 框架在配置脚本里统一通过$sel()这个伪变量来执行,括号里填的是 selector 的 id。id 用点号分层,比如uri.user表示“Request-URI 的 user 字段”,msg.body表示“整个消息体”。
2.1 基础语法与求值规律
最简单的用法就是在if条件里直接比较:
if ($sel(uri.host) == "pbx.example.net") { # 命中目标主机 }这里$sel(uri.host)会在运行时对当前 SIP 消息执行求值,返回一个字符串。取不到有效值时返回空,很多情况下你可以用== $null来判断是否存在。
除了直接比较,也可以把 select 的结果先存到变量里,再做后续逻辑。我个人更推荐这种用法,原因后面会讲,主要是性能和可读性的平衡:
$var(ru_user) = $sel(uri.user); $var(ru_host) = $sel(uri.host); xlog("L_INFO", "user=[$var(ru_user)] host=[$var(ru_host)]\n");每次执行$sel()都会重新从当前消息解析结构里取一次值,不是全局缓存的。所以同一个消息在路由过程中调用多次,结果是一致的,但开销会累积。如果你在几个地方都要用同一个字段,最好先赋值给$var再复用。
2.2 核心 selector 速查
Kamailio 核心提供的 select id 不算多,但都很常用。下面这几个是我在路由脚本里高频使用的:
| selector | 作用 | 典型返回 |
|---|---|---|
uri | 请求 URI 整体 | sip:881234@client-a.example.net |
uri.user | 请求 URI 中的用户部分 | 881234 |
uri.host | 请求 URI 中的主机部分 | client-a.example.net |
uri.port | 请求 URI 中的端口,没有则为空 | 5060或空 |
uri.params | 请求 URI 中的参数部分 | transport=udp |
msg.body | SIP 消息的消息体原始字符串 | v=0\r\n... |
举个例子,如果收到一个 INVITE,请求行是INVITE sip:881234@client-a.example.net;transport=udp SIP/2.0,那么:
$sel(uri.user)返回881234$sel(uri.host)返回client-a.example.net$sel(uri.params)返回;transport=udp或transport=udp,具体格式在不同版本里可能略有差异
这几个 selector 的价值在于:你不需要自己写正则去拆$ru字符串,框架已经帮你拆好了。尤其是带参数的 URI,用正则抠参数很容易踩边界条件,select 直接给字段就清爽很多。
2.3 模块也可以注册自己的 selector
“框架”这两个字是有实际含义的。Kamailio 核心只实现了一部分 select id,其它模块可以在初始化时往 select 注册表里挂自己的函数。这样配置脚本就能用统一的$sel()语法,访问模块内部解析出来的扩展数据。
这意味着你在一个发行版里看到的 select id,换到另一个精简编译版本可能就没有了。所以遇到不认识的 selector,第一反应应该是去查对应模块的文档,而不是怀疑语法写错。特别提醒一句:select 的 id 并不是越多越好,核心模块提供的 id 在所有版本里相对稳定,第三方模块的 id 则可能随版本变化,升级时尤其要注意。
3. 完整实例:按 URI 字段做双中继分流
理论讲再多,不如一份能跑起来的配置。下面这个例子是我在实际项目中简化出来的场景,用来演示 select 框架在路由决策里的完整使用链路。
3.1 需求拆解
假设你是企业 SIP 中继网关,有两个客户域通过你的网关对外呼叫:
| 客户域 | 用户号段 | 出局中继 |
|---|---|---|
client-a.example.net | 以88开头 | 10.10.1.20:5060 |
client-b.example.net | 以99开头 | 10.10.2.20:5060 |
其它来源的 INVITE 一律拒绝。这个需求的关键点就是“从 Request-URI 里拆出 host 和 user,再做两级匹配”。用 select 来做是再合适不过的。
3.2 可运行的 cfg 片段
下面是一段精简但完整的路由逻辑,依赖tm和sl两个模块:
request_route { # 非 INVITE 直接走默认中继 if ($rm != "INVITE") { route(RELAY); exit; } # 用 select 把关键字段一次性取出来 $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 怎么验证 select 真的取对了值
配置写完先别急着上线,第一步先做语法检查:
kamailio -c语法检查只能保证格式没错,不能保证 select 取到了你期望的值。我习惯的做法是先启动 Kamailio,然后用nc发一个 UDP 的 INVITE 测试消息,看日志里 xlog 的输出。
测试消息长这样:
INVITE sip:881234@client-a.example.net;transport=udp SIP/2.0 Via: SIP/2.0/UDP 192.168.1.10:5060;branch=z9hG4bK-test1 Max-Forwards: 70 From: <sip:1001@192.168.1.10>;tag=1001 To: <sip:881234@client-a.example.net> Call-ID: test-select-001 CSeq: 1 INVITE Contact: <sip:1001@192.168.1.10> Content-Length: 0保存成文件后用nc发过去:
nc -u 127.0.0.1 5060 < test_invite.txt正常的话,Kamailio 日志里会打出这样一行:
select: user=[881234] host=[client-a.example.net] params=[;transport=udp]如果 host 或 user 取出来是空的,说明你的 selector 在当前版本里不存在,或者消息本身格式有问题。这一步能帮你把“select 用错了”和“路由逻辑错了”快速区分开。
4. 别把 select 当万能钥匙:与伪变量、字符串变换、模块化解析的取舍
select 框架好用,但它不是万能的。我在项目里经常看到有人把所有字段提取都堆到 select 上,结果代码既啰嗦又不好维护。说到底,select 只是工具之一,选型要看场景。
4.1 select 与普通伪变量的对比
先看一张对比表:
| 维度 | select($sel()) | 普通伪变量($ru、$rd、$hdr()等) |
|---|---|---|
| 读写性 | 只读 | 大多可读可写 |
| 表达力 | 点号路径清晰,适合拆字段 | 短,但整块读取时需二次处理 |
| 典型场景 | 从 URI/消息体中提取子串做判断 | 快速读取、修改路由目标 |
| 版本差异 | 第三方模块 id 可能变化 | 核心伪变量相对稳定 |
| 调试方式 | 可直接赋给变量打印 | 同样可打印 |
这里面最容易被忽视的是“可写性”的差异。select 是只读的,它只是从消息结构里取字符串给你看,不能改。如果你想把请求 URI 的 host 改成新的值,正确做法是用$rd这类可写伪变量:
# 错误示范,select 不能赋值 $sel(uri.host) = "new.example.net"; # 正确做法:需要改 host 时用可写伪变量 $rd = "new.example.net";虽然这个例子很基础,但我在代码评审里见过不止一次有人想“省一步”直接对 select 赋值,结果配置加载失败。
4.2 select、字符串变换与正则怎么分工
Kamailio 的字符串变换可以做到类似 select 的提取效果,比如对$ru做各种变换取出部分字段。但我的原则是:能靠字段路径说清楚的事,就不要引入变换链。变换适合在原有值的基础上做格式化,比如把 user 部分取出来后去掉前缀、或把某个伪变量做大小写转换;而 select 的定位更偏“结构化读取”。
正则也是一样。用=~直接从$sel(uri.params)里判断是否包含transport=udp是没问题的:
if ($sel(uri.params) =~ "transport=udp") { # UDP 传输 }但如果整个路由逻辑里全是正则去抠 URI 字段,代码的可读性会很差,出问题后也不好查。select 的价值就在于此:把字段边界交给框架,而不是交给你自己写的正则表达式。
4.3 复杂结构数据要交给专业模块
select 框架能拿到msg.body,但拿到的是原始字符串。如果 body 是 SDP、JSON、XML 这类有复杂结构的数据,我不建议用 select 加正则去解析。SDP 内容判断用sdpops模块的相关函数,JSON 解析用json或jansson模块,数据库查询结果有专门的变换和函数。
简单说,select 的边界在“SIP 消息的原始字段”,而不是“字段内容的深加工”。你可以在调试阶段用 select 快速看一眼 body 里有没有某个关键字,生产环境里涉及到媒体协商、数据提取,还是要走专业模块。
5. 用 select 框架时踩过的几个隐蔽坑
这部分是我最想写的。select 框架本身不复杂,但实际使用中有一堆“文档里没有,但线上会炸”的细节。
5.1 selector 拼错了,启动不报错,运行时静默返回空
这个坑我印象最深。当时我写了一个$sel(uri.hostname)去判断主机名,kamailio -c语法检查通过了,但路由就是不命中。查了半天才发现,selector id 是注册表驱动的,未知 id 在运行时不会直接报错,而是求值成空字符串。if ($sel(uri.hostname) == "example.net")永远为假。
排查方法其实很简单:把 select 结果打出来看。
$var(dbg) = $sel(uri.hostname); xlog("L_INFO", "debug select: [$var(dbg)]\n");日志里如果打出空括号,基本可以确定 selector id 不对。所以拿到陌生配置时,不要盲目信任里面写的 id,先去当前版本的文档里核对一遍模块提供的 select 列表。
5.2 大字段反复取,性能容易出问题
msg.body这种字段可能很大,一个带 SDP 的 INVITE body 动辄几百上千字节。如果你在路由脚本里重复写$sel(msg.body),每次执行都是重新走一遍查找和字符串返回,相当于同一份大字符串被反复翻出来。Kamailio 的路由脚本本身不适合做重逻辑,这种浪费更不值得。
我的习惯是:如果一个字段要在多处使用,第一时间赋值给$var,后面都用变量:
$var(body) = $sel(msg.body); if ($var(body) != $null) { # 业务判断 }这样既保证结果一致,也避免重复求值。尤其是处理 body 这种大字段时,缓存变量的好处立竿见影。
5.3 只读语义引发的心态崩溃
select 是只读的,这从设计上是优点,但很多人一开始不知道。有位同事迁脚本时想当然地写了类似$sel(uri.host) = "xxx"的代码,想着“既然能读到,应该也能写回去”,结果配置反复加载不过,还以为是语法问题。最后改成用$rd赋值才解决。
这点值得反复强调:select 只负责“读”和“判断”,需要“改”的时候换伪变量,需要“深层解析”的时候换模块函数。把工具边界理清楚,脚本维护成本会低很多。
5.4 老脚本迁移别搞“一刀切”
从旧@写法迁移到$sel()或伪变量时,最忌讳一次性全局替换。旧写法里有些 selector 在新版里语义可能有细微差别,比如对畸形 URI 的处理、对空值的返回方式。我建议分两步走:
第一步,先加日志,把每个旧 select 的真实值和预期值打印出来,确认当前版本解析结果是什么。
第二步,逐行替换并回归测试。举个例子,旧写法里@to.uri.host这种含义,在新配置里我会优先用$tH这类专门的伪变量,而不是硬套$sel()。伪变量更短,语义也更明确。
老脚本迁移的完整流程不适合在一篇文章里铺开,但记住一个原则:先看清真实求值结果,再动手改代码。离开日志做重构,跟蒙眼开车差不多。
最后再分享一个习惯:我现在写 Kamailio 路由脚本时,凡是涉及 Request-URI、From/To 头、消息体这类“结构化字段提取”,第一反应就是用 select 把字段抽出来交给$var,再走后续分支;凡是涉及修改消息目标,立刻切回可写伪变量。这样做了一段时间之后,脚本里几乎见不到大段正则抠字段的代码了,排障时每条 xlog 都能直接看出 select 取到了什么,思路清楚很多。