ROS机器人-从零开始每日日志记录 Day14:getattr(nlog, level)(msg) 是什么?为什么会撞上 rclpy 调用点 severity 锁?
摘要:
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")
我最初的疑惑有三个:
getattr(nlog, level)(msg)为什么能输出日志?- 外面两次调用明明写在不同行,为什么 rclpy 还认为它们是“同一个调用点”?
- 为什么日志级别从
INFO变成ERROR,就会报 severity 相关错误?
先给结论:
getattr本身没有问题。真正的问题是:同一个 rclpy 日志调用点,第一次使用了INFO,第二次却改成了ERROR。
本文依据 ROS 2 Humble 的 rclpy 源码整理,属于源码机制学习笔记;文中的触发过程是按源码推导的示例,不把它描述成已经完成的实机复现。其他 ROS 2 发行版的实现可能不同,需要以对应版本源码为准。
二、先分清几个关键概念
| 名词 | 在本文中的含义 |
|---|---|
rclpy | ROS 2 的 Python 客户端库,本文关注它的日志实现 |
severity | 单条日志的严重程度,例如 INFO、ERROR |
call site | 调用点,即日志调用在源代码中的具体位置,这是本文的核心 |
CallerId | rclpy 用来识别调用点的一组信息 |
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 就是查找对应状态的标识。
这段逻辑可以翻译成三步:
- 第一次来到某个调用点,保存它的
severity。 - 后续再次来到同一个调用点,取出之前保存的
severity。 - 如果新级别和旧级别不同,就抛出
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,不要只凭报错名称猜实现。
九、总结
变量的值可以变化,但执行调用的源代码位置可以始终不变。
getattr(nlog, level)(msg)的意思是:根据level字符串动态找到日志方法,再输出msg。- 这种包装函数会把来自不同位置的日志,集中到函数内部的同一个日志调用点。
- 在 Humble 的 rclpy 实现中,同一调用点保存的 severity 不能在后续调用中改变。
参考资料
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)