
看到 select 这个词很多人第一反应是 SQL 里的查询语句或者是 IDE 里哪个按钮的名字。但在 Kamailio 的世界里select 是一套从 OpenSER 时代就存在的消息字段提取机制专门用来从 SIP 消息、URI、头部结构里取数据。我最初的理解特别浅觉得既然有$rU、$ru、$fu这些伪变量select 存在的意义是什么直到一次路由改造需要拿 From 的 tag、判断请求带几个 Via、还要从 R-URI 参数里取业务标识才发现普通伪变量根本不够用。这篇文章就把我对 Kamailio select 的理解、用法和一些踩坑教训写下来给同样在配置脚本里和 select 纠缠的兄弟们一点参考。1. select 到底在选什么先把概念掰清楚1.1 一个“按名取数”的查询框架Kamailio 收到一个 SIP 请求后内部会做解析把消息拆成一个个结构化对象头部、URI、事务、路由记录等。平时我们用的$ru、$rU这些伪变量本质上是从这些对象里取某个固定字段。但如果想要更细、更灵活的数据比如“From 头的 tag 字段”“第二个 Via 头”“URI 里的参数部分”伪变量就有点接不住了。select 解决的就是这个问题。它提供了一个统一的“按名取数”入口语法是name或者$sel(name)返回的都是字符串。你可以在if条件里直接比较可以塞进xlog打日志也可以赋值给 AVP 或$var继续用。打个比方select 就像一张体检报告单你不需要知道血液样本是怎么被分析的只要按着项目名去查就能拿到对应指标。from.uri、ruri.user、via.2这些名字就是报告单上的检查项名字写对了数据就给你。这套机制最大的价值是省事。不用自己写正则去抠字符串不用把整个 SIP 消息翻来覆去地找只要你记得 select 的名字它就能把解析后的字段直接丢给你。1.2 和伪变量、$hdr 的分工刚接触 select 的人很容易陷入一个困惑既然有伪变量为什么还要 select它们到底有什么区别我在项目里用下来的体会是它们不是替代关系而是分工关系。常见伪变量提供的是“高频便捷访问”$ru整个 Request-URI 字符串$rURequest-URI 的 user 部分$rdRequest-URI 的 domain 部分$fu、$fU、$fdFrom 头的 URI、用户名、域名$tu、$tU、$tdTo 头对应的字段这些变量写起来短日常路由判断很好用。但它们拿到的东西边界固定例如你想取“R-URI 里分号后面的参数部分”伪变量里没有一个专门给你的。$hdr(name)则是另一个维度它不做解析把指定头的原始文本取出来。比如$hdr(From)返回整行包含显示名、尖括号、参数需要你再去处理。select 正好补上中间这层它返回的是解析后的子字段。举个很直观的例子$hdr(From)可能返回Alice sip:1001example.com;tagabcdfrom.uri返回sip:1001example.comfrom.tag返回abcd这样一来如果你只关心 From 的 URI就完全不用碰正则也不会被显示名干扰。这就是 select 的核心定位结构化数据访问。1.3 什么时候我才会想到用它实际项目里我切换到 select 通常是因为这几个信号之一需要拿tag参数。无论是 From 还是 Totag在消息路由、对话关联、防止环路时经常用到伪变量不直接给你select 一行就能拿到。需要区分相同名字的头部。比如一个请求经过了两级代理会有两个 Via 头$hdr(Via)默认取第一个还是多个返回格式不可控。select 用via.1、via.2这种索引方式明确拿到第几个。需要从 URI 参数里做业务判断。比如sip:1000example.com;billingprepaid$ru给你整个 URI你还得处理参数select 可以把参数部分取出来再做匹配。需要打印调试日志。在xlog里输出结构化的字段信息select 非常顺手尤其在排查消息哪里不对时。一句话总结遇到伪变量给不了、$hdr又太原始的情况第一反应就该是“有一个 select 能解决”。2. 语法与常用字段照着写就能跑2.1 两种等价写法Kamailio 的 select 有两种写法功能等价name $sel(name)比如取 From 的 URIfrom.uri $sel(from.uri)两种写法在if、赋值、xlog里都可以用。但我在自己的项目里更推荐$sel(...)原因有两个一是它长得很像其他伪变量比如$var(...)、$avp(...)放进一大段配置里可读性更好二是它可以直接嵌在字符串里例如xlog(L_INFO, from[$sel(from.uri)] tag[$sel(from.tag)]\n);name这种写法在条件判断里比较直观例如if (via.2 )一眼就能看出是在判断第二个 Via 是否存在。两种都可以关键是一个脚本里尽量统一别混着来否则后面维护的人会骂人。2.2 我常用的核心 select 字段每个版本、每个模块提供的 select 不完全一样但核心的字段基本是稳定的。下面这张表是我在 Kamailio 5.x 系列上常用的可以直接抄表达式返回内容备注msg完整的 SIP 消息文本调试神器但内容可能很长via.1第一个 Via 头索引从 1 开始不是 0via.2第二个 Via 头可以用来判断代理跳数from.uriFrom 头的 URI不包含显示名和 tagfrom.tagFrom 头的 tag 参数空串表示没有 tagto.uriTo 头的 URI用法同 Fromto.tagTo 头的 tag 参数空串表示没有 tagruri.userRequest-URI 的 user 部分和$rU等价ruri.hostRequest-URI 的 host 部分和$rd等价ruri.paramsRequest-URI 的参数部分从分号开始的内容callidCall-ID 头会话追踪常用cseqCSeq 头比如123 INVITE这里有一个很实用的细节URI 相关的 select 可以继续加子字段。比如from.uri.host可以直接取出 From URI 里的域名部分不用先从from.uri拿到完整 URI 再二次处理。语法看着像级联其实就是 select 框架帮你把 URI 对象又往下拆了一层。同样ruri.params获取的是 Request-URI 分号后面的部分。不同版本对这个字段的命名可能略有差异如果你在当前版本里发现这个字段取不到值先别慌用ruri或者$ru取完整 URI 再配合正则兜底同时去查一下当前版本的 Core Cookbook。2.3 最小可用示例为了快速验证 select 在你环境里好不好使可以先跑一个最精简的脚本route { xlog(L_INFO, callid[$sel(callid)] from[$sel(from.uri)] ruri_user[$sel(ruri.user)]\n); if (ruri.user 2000) { xlog(L_INFO, match user 2000\n); } exit; }用kamailio -c检查语法没问题后启动服务用 SIPp 或者任意 SIP 客户端发一个 OPTIONS 请求日志里就能看到 select 返回的实际内容。第一次接触 select 的人建议先做这个动作比干看文档强得多。3. 实战三个能直接抄的场景3.1 按 From 域做线路分流我之前接到过一个需求所有 From 域名为sp-a.example.com的呼叫统一送到线路 A其他域名送到线路 B。最容易想到的方案是用$hdr(From)加正则但这里有个坑From 头如果有显示名或tag正则表达式就要把各种情况都考虑进去写起来又长又容易漏。用 select 会清爽很多route[FROM_DOMAIN_ROUTE] { $var(from_domain) $sel(from.uri.host); if ($var(from_domain) sp-a.example.com) { $du sip:10.0.1.10:5060; xlog(L_INFO, route to line A, domain[$var(from_domain)]\n); } else { $du sip:10.0.1.20:5060; xlog(L_INFO, route to line B, domain[$var(from_domain)]\n); } }这里的$sel(from.uri.host)直接把 From URI 里的 host 取了出来不需要正则不需要考虑显示名、尖括号、分号。代码的意思是先把要判断的值存到一个临时变量里再和已知线路域名做精确匹配最后设置目的地 URI$du。为什么先存$var再判断因为如果你在if条件里反复写$sel(from.uri.host)一旦后面还要用这个值就得重复取好几次。存到$var里后面的日志、其他分支都可以直接复用代码也干净。3.2 从 R-URI 参数里取业务标识还有一种很常见的场景上游把业务属性放在 Request-URI 的参数里比如sip:1000example.com;billingprepaid。我们需要根据billing参数决定走预付费还是后付费逻辑。常规做法是拿$ru整个 URI 去正则匹配麻烦的是 URI 里还有sip:、userhost这些部分正则容易误伤。用 select 把参数部分先摘出来逻辑就清晰了route[BILLING_DECIDE] { $var(uri_params) $sel(ruri.params); xlog(L_INFO, ruri params[$var(uri_params)]\n); if ($var(uri_params) ~ billingprepaid) { # 走预付费流程 route(PREPAID_FLOW); } else if ($var(uri_params) ~ billingpostpaid) { # 走后付费流程 route(POSTPAID_FLOW); } else { # 参数缺失或未知按默认后付费处理 route(POSTPAID_FLOW); } }这里注意ruri.params返回的是参数部分可能是空字符串也可能包含多个;分隔的参数。用~做正则匹配只要billingprepaid这个子串出现就能命中不会被其他参数干扰。如果某些版本里ruri.params不可用兜底方案是$var(ru_tmp) $ru; # 然后用 Kamailio 的字符串函数或正则把分号后面的部分抽出来但能直接用 select 的时候我建议直接用代码最省。3.3 用 Via 序号判断代理跳数SIP 消息每经过一个 SIP 代理都会在头部加一个 Via。所以 Via 的数量在一定程度上代表了这个请求已经过了几跳。这个信息在防环路、限制转发深度时很有用。用 select 的索引能力可以这样判断route[VIA_DEPTH_CHECK] { if (via.2 ) { # 没有第二个 Via说明这是第一跳正常转发 route(TO_UPSTREAM); } else { # 已经有第二个 Via说明消息可能出现了环路 sl_send_reply(482, Loop Detected); exit; } }这里的逻辑很直观via.2取第二个 Via为空表示只有一个 Via请求是第一次进到我们的代理不为空说明前面已经有过一跳继续转发可能形成环路。有一点必须强调select 的索引从 1 开始。via.1是第一个 Viavia.2是第二个没有via.0。我见过有人按编程语言的习惯写via.0然后发现返回永远是空排查半天。4. 别再硬选哪些场景其实不该用 select4.1 它是只读视图不是可写变量select 是只读的只能用来取数据不能赋值。这一点和$var、$avp有本质区别。有些兄弟刚上手可能想当然地写类似$sel(ruri.user) 1000这种代码这在 Kamailio 里是行不通的脚本解析阶段就会报错。select 的定位是“查”不是“改”。需要保存结果时可以这样$var(u) $sel(ruri.user);然后后面就可以用$var(u)继续判断、拼接、打日志。4.2 要改 URI、改头请换别的工具如果你要修改 Request-URI 的 user 或 host直接用$rU、$rd赋值或者用$ru设置整个 URI这才是正确姿势。比如$ru sip:1001example.com; $rU 1002;类似地要新增或删除头部用append_hf、remove_hf等函数。select 只是读取结构不参与消息修改。把工具用对地方脚本才不容易出问题。4.3 热路径和循环里斟酌一下Kamailio 底层对已解析的消息有缓存select 的读取开销通常不大。但这不是让你肆无忌惮地在热路径里反复调用同一组 select。如果某个 select 的值在一条路由流程里要用很多次最好在入口取一次存到$var或 AVP 里后面直接用变量。比如# 不好的写法每个 if 里都重新取 if ($sel(from.uri.host) a.com) { ... } if ($sel(from.uri.host) b.com) { ... } # 更好的写法取一次存起来 $var(fh) $sel(from.uri.host); if ($var(fh) a.com) { ... } if ($var(fh) b.com) { ... }这不仅是性能问题也是可维护性问题。统一取一次后面逻辑改起来也方便。4.4 模块级 select 的版本陷阱除了核心 selectKamailio 的一些模块也会提供自己的 select 或伪变量。模块提供的字段没有核心字段那么稳定升级 Kamailio 时可能被改名甚至被废弃。我在一次大版本升级后就遇到过原来某个模块 select 还能返回数据升完级直接返回空。所以用模块级 select 时一定要看对应模块的文档并且带上版本号。别把旧文档里的字段名直接复制到新版本里轻则取不到值重则脚本加载失败。我的习惯是模块相关的字段全部单独抽出来写在升级时重点回归测试。5. 踩坑记录select 用不好真的会翻车5.1 索引从 1 开始不是 0这是最经典的坑。via.1是第一个 Viavia.2是第二个。没有via.0写了也不会主动报错只是返回空串。如果你从 Java、Python 过来潜意识里会用 0 开头这个习惯在 Kamailio 里一定要改掉。另外多实例头部的 select 不一定都支持*或者last这类通配最稳妥的做法就是显式写数字。想遍历未知数量的 Via脚本配置里没有 for 循环这种便利工具一般也就是判断前几个 Via 是否存在够用就行。5.2 字段不存在时返回空字符串不会报错这是 select 最阴险的地方。你写了一个当前消息里不存在的字段比如普通 OPTIONS 请求里可能没有 Contact 头select 不会炸而是安静地返回空串。如果后面的逻辑没有意识到空串的可能性就可能被带偏。我举个例子if (from.tag ! ) { # 有 tag 的分支 route(HAS_TAG); } else { # 没有 tag 的分支 route(NO_TAG); }这种写法本身没问题怕就怕你反过来用from.tag 去判断“没有 tag”然后以为所有空串场景都一样结果把“字段不存在”和“字段值为空”混为一谈。对 SIP 消息来说这两个状态通常是一回事但如果某个场景里“字段不存在”意味着协议异常那你就要在前面再叠加一层检查不能只看 select 的结果。5.3 拿到的不一定是“当前值”select 读取的是 Kamailio 解析后的消息结构。问题在于脚本运行过程中你可能会改$ru、改头部、加头部。改了之后再去读 select它返回的是最新解析结果还是请求刚进来时的原始快照这取决于具体字段和模块实现。我踩过一次很实际的坑脚本里先把$ru改成了新的 URI后面再用$sel(ruri.user)判断发现结果和我预期不一致。原因就是 select 读取的数据源和$ru这个伪变量背后的存储并不是同一个同步机制。所以我的建议是如果你在脚本里动过 URI 或者相关头部再用 select 时先打印一眼结果确认不要凭直觉推断。调试命令就是加一行xlog把修改前后值都打出来对比一目了然。5.4 xlog 调试时记得把值括起来别笑这个细节真的很重要。打印 select 结果时如果只是写xlog(L_INFO, from uri is $sel(from.uri)\n);当它返回空串时日志里看起来就是from uri is你根本分不清是空串还是被截断了。正确做法是加上可辨识的前后缀xlog(L_INFO, from[$sel(from.uri)] tag[$sel(from.tag)]\n);这样空值显示为from[] tag[]一眼就能看出来。日志里也要记得加\n否则多条日志挤在一起排查的时候会想哭。5.5 返回值本质都是字符串Kamailio 配置脚本里的 select、伪变量本质上返回的都是字符串。没有 int、bool 这些类型判断的时候千万别套用编程语言的类型思维。例如if ($sel(ruri.user) 1000) { # 这里 1000 会被当作字符串 1000 处理 }在 Kamailio 里这样写通常也能工作因为它在比较时会做隐式转换。但为了避免歧义我习惯在脚本里统一用带引号的字符串比较if ($sel(ruri.user) 1000) { ... }特别是数字开头可能带前导零的情况比如用户部分是01000如果你在别的地方做了数值转换前导零丢了后面的匹配就会出错。这类问题排查起来极其隐蔽。6. 我自己的习惯和一个扩展思路6.1 新环境先做一次 select 字段“摸底”每到一个新版本环境或者接触一套新配置我都会先写一个临时的调试路由把会用到的 select 字段全部打印一遍route { xlog(L_INFO, DEBUG sel: msg[$sel(msg)] via1[$sel(via.1)] via2[$sel(via.2)] from[$sel(from.uri)] from_tag[$sel(from.tag)] to[$sel(to.uri)] ruri[$sel(ruri)] callid[$sel(callid)]\n); exit; }让一台测试话机发一个正常请求再发一个异常请求对比日志里的差异。这个过程只要做一次就能避免很多“字段名写错”“版本不支持”这些低级别但又特别浪费时间的问题。6.2 把 select 当作取数层和 htable 搭配select 善于取数Kamailio 的 htable 模块善于做内存表匹配两者配合起来很自然。比如你可以根据from.uri.host取出域名拼接一个白名单 key再查 htable$var(domain) $sel(from.uri.host); if ($sht(domain_whitelist$var(domain)) $null) { sl_send_reply(403, Forbidden); exit; }这种写法比在脚本里堆一串if else要容易维护得多。域名的增删只在 htable 里操作不用每改一个域名就改脚本、重启服务。好了关于 Kamailio 的 select 就聊到这里。最后再提醒一句无论你看的是哪篇博客最终都要以你当前版本的 Core Cookbook 为准。Kamailio 迭代很快字段命名、模块里的 select 都可能调整。先在自己的环境里把 select 打印一遍胜过看十篇过时的教程。