摘要:getattr(nlog, level)(msg)可以根据字符串动态选择 info()、error() 等日志方法,但如果把这句代码固定在同一个包装函数里,不同日志级别就会共享同一个 rclpy 调用点,从而触发 Logger severity cannot be changed between calls.。本文从 Python 语法、调用点、CallerId 和 Humble 源码四个层面把问题讲清楚。

一、今天要解决什么问题?

先看出问题的核心写法:

def emit_log(level, msg):
    getattr(nlog, level)(msg)


emit_log("info", "ready")
emit_log("error", "failed")

我最初的疑惑有三个:

  1. getattr(nlog, level)(msg) 为什么能输出日志?
  2. 外面两次调用明明写在不同行,为什么 rclpy 还认为它们是“同一个调用点”?
  3. 为什么日志级别从 INFO 变成 ERROR,就会报 severity 相关错误?

先给结论:

getattr 本身没有问题。真正的问题是:同一个 rclpy 日志调用点,第一次使用了 INFO,第二次却改成了 ERROR。

本文依据 ROS 2 Humble 的 rclpy 源码整理,属于源码机制学习笔记;文中的触发过程是按源码推导的示例,不把它描述成已经完成的实机复现。其他 ROS 2 发行版的实现可能不同,需要以对应版本源码为准。

二、先分清几个关键概念

名词在本文中的含义
rclpyROS 2 的 Python 客户端库,本文关注它的日志实现
severity单条日志的严重程度,例如 INFO、ERROR
call site调用点,即日志调用在源代码中的具体位置,这是本文的核心
CallerIdrclpy 用来识别调用点的一组信息

nlog 只是日志记录器的变量名。以下假设它已经创建,代码均为聚焦核心机制的片段,而不是完整节点程序。

三、getattr(nlog, level)(msg) 到底是什么?

getattr 是 get attribute 的缩写,关键作用是根据字符串名称取得对象的方法。这里取得的是日志方法,随后立即调用:

log_func = getattr(nlog, level)
log_func(msg)

因此:

level = "info"
getattr(nlog, level)(msg)  # 等价于 nlog.info(msg)

把 level 改成 "error",同一行就等价于 nlog.error(msg)。这里的“动态”就是指:调哪个方法由运行时的 level 决定,而不是直接写死。

如果 level 对应的方法不存在,getattr() 会抛出 AttributeError。

四、“永远是这个调用点”是什么意思?

给最开始的代码加上行号来理解:

1  def emit_log(level, msg):
2      getattr(nlog, level)(msg)
3
4  emit_log("info", "ready")
5  emit_log("error", "failed")

第 4 行和第 5 行确实是两个不同的调用点,但它们调用的是我自己写的普通函数 emit_log()。

rclpy 在这里关心的是:**日志方法最终从哪一行被调用?**答案是两次都来到第 2 行。

第一次调用

emit_log("info", "ready")

执行过程:

第 4 行调用 emit_log
        ↓
进入 emit_log 函数
        ↓
来到第 2 行
        ↓
getattr 根据 "info" 找到 nlog.info
        ↓
实际效果:nlog.info("ready")

rclpy 识别到的日志调用点是第 2 行,并在这个调用点下记录 INFO。

第二次调用

emit_log("error", "failed")

执行过程:

第 5 行调用 emit_log
        ↓
再次进入同一个 emit_log 函数
        ↓
再次来到同一个第 2 行
        ↓
getattr 根据 "error" 找到 nlog.error
        ↓
实际效果:nlog.error("failed")

日志调用点仍然是第 2 行,但级别变成了 ERROR。

因此,rclpy 看到的是:

同一个第 2 行:第一次使用 INFO,第二次使用 ERROR

这就是“永远是这个调用点”的含义。它不是“这行代码一直在执行”,也不是“内存地址永远不变”,而是:

无论外面从哪里调用,最终都经过同一个源代码位置输出日志。方法会变,位置不变。

五、rclpy 怎样识别同一个调用点?

Humble 的 rclpy 会用 function_name、file_path、line_number 和 last_index 构造 CallerId,也就是结合函数、文件、行号和指令位置识别调用点。

前三项比较直观;last_index 用来进一步区分同一行中的不同调用。只要这四项相同,rclpy 就会把它当作同一个调用点。

在前面的例子中,getattr() 只改变了要调用的方法,并没有改变这张“身份证”。

六、Humble 源码为什么会报 severity 错误?

下面是 rclpy/rclpy/impl/rcutils_logger.py 中核心逻辑的精简摘录,省略了日志过滤器的处理:

caller_id = CallerId()

if caller_id not in self.contexts:
    context = {'name': name, 'severity': severity}
    self.contexts[caller_id] = context
else:
    context = self.contexts[caller_id]
    if severity != context['severity']:
        raise ValueError(
            'Logger severity cannot be changed between calls.')

这里的 self.contexts 保存了各个调用点的状态,CallerId 就是查找对应状态的标识。

这段逻辑可以翻译成三步:

  1. 第一次来到某个调用点,保存它的 severity。
  2. 后续再次来到同一个调用点,取出之前保存的 severity。
  3. 如果新级别和旧级别不同,就抛出 ValueError。

对应到本文示例:

同一个 CallerId
第一次保存:severity = INFO
第二次传入:severity = ERROR
INFO != ERROR
最终抛出 ValueError

报错原文:

Logger severity cannot be changed between calls.

意思就是:同一个调用点,不能在多次调用之间改变日志级别。

这里的“severity 锁”不是线程锁

“调用点 severity 锁”只是为了帮助理解而使用的形象说法,不是这道报错的官方名称。它指的是:某个调用点第一次保存级别后,后续调用不能改成另一个级别。

它也和 set_level() 不是一回事:

  • set_level():设置日志记录器的输出阈值,也就是允许哪些级别的日志输出。
  • 本文的调用点检查:限制同一个调用点不能在多次调用之间改变该条日志的 severity。

七、怎样修改?给不同级别保留不同调用点

最直接的修改方法,是把不同级别的日志调用分别写在不同源码行:

def emit_log(level, msg):
    if level == "debug":
        nlog.debug(msg)
    elif level == "info":
        nlog.info(msg)
    elif level == "warning":
        nlog.warning(msg)
    elif level == "error":
        nlog.error(msg)
    elif level == "fatal":
        nlog.fatal(msg)
    else:
        raise ValueError("不支持的日志级别")

这样,nlog.debug()、nlog.info()、nlog.error() 等调用位于不同源码行,各自拥有不同的调用点,不会因为共享一个 CallerId 而发生上述 severity 冲突。

最后的 else 还有一个好处:当 level 写错时,程序会直接指出问题,而不是静默忽略日志。

只改成 nlog.log(msg, severity) 能彻底解决吗?

不能把它当成通用解决方案。

如果仍然把 nlog.log(msg, severity) 固定在同一个包装函数中,同时让 severity 随调用变化,那么它依然对应同一个调用点,仍然可能触发相同的检查。

同样,把多个日志方法先保存起来,最后仍通过同一行统一调用,也没有从本质上改变“同一个调用点切换级别”这件事。

八、几个容易混淆的问题

1. 是不是不能使用 getattr?

不是。getattr() 是合法的 Python 动态属性访问方式。问题出在它与 rclpy 的“按调用点保存状态”机制组合后,让不同级别共享了同一个调用点。

2. 为什么外面的 emit_log 在不同行还不够?

因为外面不同行只是 emit_log() 的调用点不同;进入函数后,真正的日志输出仍然集中在同一行。

3. 同一个调用点是不是什么都不能变?

不是。日志消息 msg 可以变化。本文冲突的关键是 severity 发生变化;Humble 源码对 logger 名称和日志过滤器(决定某条日志是否输出的规则)配置也有相应的调用点一致性检查。

4. 直接分行写 info 和 error 可以吗?

可以:

nlog.info("ready")    # 日志调用点 A
nlog.error("failed")  # 日志调用点 B

这里是两个不同的日志调用点,各自使用固定级别,不会产生本文讨论的冲突。

5. 这个结论能直接套到所有 ROS 2 版本吗?

不能笼统保证。本文核对的是 Humble 的实现;学习或排查其他发行版时,应查看对应版本的 rcutils_logger.py,不要只凭报错名称猜实现。

九、总结

变量的值可以变化,但执行调用的源代码位置可以始终不变。

  1. getattr(nlog, level)(msg) 的意思是:根据 level 字符串动态找到日志方法,再输出 msg。
  2. 这种包装函数会把来自不同位置的日志,集中到函数内部的同一个日志调用点。
  3. 在 Humble 的 rclpy 实现中,同一调用点保存的 severity 不能在后续调用中改变。

参考资料

  1. Python 官方文档:getattr
  2. ros2/rclpy Humble:rcutils_logger.py
  3. ros2/rclpy Humble:logging_severity.py
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐