大家好,欢迎大家关注我的知乎专栏慢慢悠悠小马车
本文是继 BT11:行为树内外的数据传输 - 知乎 之后更深入的从源码角度分析BehaviorTree.CPP的Blackboard的数据传输机制,是这一系列文章的精华。
树内即ports之间
类Blackboard(以下简称BB或bb)有3个重要的数据成员:BT中数据的保存依赖于storage_(Entry集合),树间的映射依赖于parent_和internal_to_external_。
// 保存了blackboard的所有entry的信息,包含entry所对应的port的实时值
std::unordered_map<std::string, Entry> storage_;
// 指向父blackboard(父树的blackboard)的指针
// 若不是nullptr,说明该tree被其他树引用了,是subtree
std::weak_ptr<Blackboard> parent_bb_;
// 保存了blackboard中向外(向父blackboard)重映射的port名称
std::unordered_map<std::string, std::string> internal_to_external_;
struct Entry {
Any value; // port的值
const PortInfo port_info; // port的其他信息
Entry(const PortInfo& info) : port_info(info) {}
Entry(Any&& other_any, const PortInfo& info)
: value(std::move(other_any)), port_info(info) {}
};
我们可以理解为:node的数据读写通过port,但数据是放在对应着该port的Entry中,树中所有nodes的数据整体放在blackboard的storage_中。这是通过xml中如下语句实现的,EntryName就是storage_中元素的第1项string,“{EntryName}”是1个blackboard pointer。
<NodeName PortName="{EntryName}" />
上面的语句不涉及树之间的关系,所以对internal_to_external_没影响。
<root main_tree_to_execute = "MainTree" >
<BehaviorTree ID="MainTree">
<Sequence>
<SaySomething message="666" />
<ThinkWhatToSay text="{the_answer}"/>
<SaySomething message="{the_answer}" />
</Sequence>
</BehaviorTree>
</root>
以BehaviorTree.CPP/examples/t02_basic_ports.cpp中的树为例,如上。当树构建第1个SaySomething节点时,在XMLParser::Pimpl::createNodeFromXML()中,会将pair{message,666}存入该node.config_.input_ports中。其中,config_是NodeConfiguration类型,input_ports是PortsRemapping类型,即unordered_map<string, string>类型。因为“666”是普通的字面字符串,不是blackboard pointer(不带花括号),就与blackboard无关,数据是静态的,node构建后就不会改变,所以这个数据是存在node自身的数据结构中,当获取名为message的port的值时,也不会去bb中查找。
struct NodeConfiguration {
Blackboard::Ptr blackboard;
PortsRemapping input_ports; // 输入port的映射关系
PortsRemapping output_ports; // 输出port的映射关系
};
当树构建ThinkWhatToSay节点时,会将pair{text,{the_answer}}存入该node.config_.input_ports中。发现"{the_answer}"是bb pointer,就会把pair{the_answer, Entry}存入所在树的bb的storage_。此时Entry还未赋值,因为树构建时节点并未运行tick(),也就没有对port数据的任何操作,仅仅定义了关系。这样,通过text port读写值,就变成了对bb的名为the_answer的Entry的操作,这就是所谓的树中节点间的数据传输靠共享的blackboard。
当树构建第2个SaySomething节点时,会将pair{message,{the_answer}}存入该node.config_.input_ports中。因为bb的storage_中已经有名为the_answer的Entry了,无需再添加了,所以storage_和internal_to_external_不会有任何改变。
至此,ThinkWhatToSay通过text port向bb的the_answer entry写入值(setOutput()),而SaySomething通过message port从同一个bb的the_answer entry读取值(getInput()),数据流和逻辑就一目了然了。
getInput()
// 获取名为key的port的值
template <typename T>
inline Result TreeNode::getInput(const std::string& key, T& destination) const {
auto remap_it = config_.input_ports.find(key);
// 既然是读值,那么就应该是input port,就应该在config_.input_ports中
if (remap_it == config_.input_ports.end()) {
return nonstd::make_unexpected(
StrCat("getInput() failed because NodeConfiguration::input_ports "
"does not contain the key: [", key, "]"));
}
auto remapped_res = getRemappedKey(key, remap_it->second);
try {
if (!remapped_res) {
// remapped_res空,说明remap_it->second目前只是普通的字面字符串
destination = convertFromString<T>(remap_it->second);
return {};
}
const auto& remapped_key = remapped_res.value();
// 既然remapped_key是本bb的一个entry对应的port的名称,那么本bb必须是有效的非空的
if (!config_.blackboard) {
return nonstd::make_unexpected(
"getInput() trying to access a Blackboard(BB) entry, but BB is invalid");
}
// 从本bb获取对应的entry的值,即port的值
const Any* val =
config_.blackboard->getAny(static_cast<std::string>(remapped_key));
if (val && val->empty() == false) {
// 做类型转换
if (std::is_same<T, std::string>::value == false &&
val->type() == typeid(std::string)) {
destination = convertFromString<T>(val->cast<std::string>());
} else {
destination = val->cast<T>();
}
return {};
}
// 没有找到对应port的entry
return nonstd::make_unexpected(
StrCat("getInput() failed because it was unable to find the key [",
key, "] remapped to [", remapped_key, "]"));
} catch (std::exception& err) {
return nonstd::make_unexpected(err.what());
}
}
getInput(key)和setOutput(key)都是先去node的config_.input_ports或config_.output_ports中寻找匹配的key。找到后,得到其匹配的字符串str。若str不是bb pointer(不带{}花括号),那就是字面字符串,就返回这个字符串,进行必要的类型转换。若str是bb pointer,得到bb entry名(去掉{}花括号),最后调用config_.blackboard->getAny(EntryName)读取值,或者调用config_.blackboard->set(EntryName)设置值,所谓“值”,就是storage_.Entry.value。
// 获取名为key的port的值
Any* getAny(const std::string& key) {
std::unique_lock<std::mutex> lock(mutex_);
// 如果父blackboard不为空,需要检查是否有向父blackboard的重映射
if (auto parent = parent_bb_.lock()) {
auto remapping_it = internal_to_external_.find(key);
// 找到了,说明存在向父blackboard的重映射
if (remapping_it != internal_to_external_.end()) {
// 从父blackboard获取对应port名为 remapping_it->second 的entry的值
return parent->getAny(remapping_it->second);
}
}
// 到这,说明父bb为空,或者名为key的port不存在重映射,那就从本bb获取值
auto it = storage_.find(key);
// 若找到了,就返回本bb中对应port名为key的entry的值
return (it == storage_.end()) ? nullptr : &(it->second.value);
}
setOutput()
// 设置名为key的port的值
template <typename T>
inline Result TreeNode::setOutput(const std::string& key, const T& value) {
if (!config_.blackboard) {
return nonstd::make_unexpected(
"setOutput() failed: trying to access a BB entry, but BB is invalid");
}
auto remap_it = config_.output_ports.find(key);
// 既然是写值,那么就应该是output port,就应该在config_.output_ports中
if (remap_it == config_.output_ports.end()) {
return nonstd::make_unexpected(
StrCat("setOutput() failed: NodeConfiguration::output_ports "
"does not contain the key: [", key, "]"));
}
StringView remapped_key = remap_it->second;
// 这种特殊情况先不管
if (remapped_key == "=") {
remapped_key = key;
}
// 如果是bb指针,就把{name}改为name,就是去掉花括号
if (isBlackboardPointer(remapped_key)) {
remapped_key = stripBlackboardPointer(remapped_key);
}
// 既然是写值,key一定对应着本bb的某个entry,从而使其他node可以通过bb共享这个数据
config_.blackboard->set(static_cast<std::string>(remapped_key), value);
return {};
}
output_port和input_port的不同在于,output_port一定会对应着bb的一个entry。因为node之所以有output_port,就是想通过它向外传值,让其他node可以获得。所以上面代码中,即便remapped_key不是bb pointer也会是一个EntryName,也要调用config_.blackboard->set(key, value)。
// 设置名为key的port的值
template <typename T>
void set(const std::string& key, const T& value) {
std::unique_lock<std::mutex> lock(mutex_);
auto it = storage_.find(key);
// 如果父blackboard不为空,需要检查是否有向父blackboard的重映射
if (auto parent = parent_bb_.lock()) {
auto remapping_it = internal_to_external_.find(key);
// 找到了,说明存在向父blackboard的重映射
if (remapping_it != internal_to_external_.end()) {
const auto& remapped_key = remapping_it->second;
// 本bb中没有对应port的entry
if (it == storage_.end()) {
// 检查父bb中是否有对应的entry
auto parent_info = parent->portInfo(remapped_key);
if (parent_info) {
// 从父bb中获取对应的entry的portinfo,保存到本bb的storage_中
storage_.insert({key, Entry(*parent_info)});
} else {
// 父bb中没有对应port的entry,在本bb的storage_中添加entry,绑定空白的portinfo
storage_.insert({key, Entry(PortInfo())});
}
}
// 向父bb的对应entry设置值
parent->set(remapped_key, value);
return;
}
}
// 到这,说明父bb为空,或者名为key的port不存在重映射
// 本bb有对应entry,检查数据类型是否匹配,并更新值
if (it != storage_.end()) {
...
} else {
// 本bb没有对应entry,就按输入值添加一个到storage_
storage_.emplace(key, Entry(Any(value), PortInfo()));
}
return;
}
SetBlackboard
SetBlackboard是一个比较特殊的节点,因为它可以直接向所在tree的blackboard或父bb写入值。其双向port“output_key”,对应着bb的一个entry。
class SetBlackboard : public SyncActionNode {
public:
SetBlackboard(const std::string& name, const NodeConfiguration& config)
: SyncActionNode(name, config) {
setRegistrationID("SetBlackboard");
}
static PortsList providedPorts() {
return {
InputPort("value",
"Value represented as a string. convertFromString must be "
"implemented."),
BidirectionalPort("output_key",
"Name of the blackboard entry where the value "
"should be written")};
}
private:
virtual BT::NodeStatus tick() override {
std::string key, value;
if (!getInput("output_key", key)) {
throw RuntimeError("missing port [output_key]");
}
if (!getInput("value", value)) {
throw RuntimeError("missing port [value]");
}
setOutput("output_key", value);
return NodeStatus::SUCCESS;
}
};
以 BehaviorTree.CPP/examples/t03_generic_ports.cpp 中的行为树为例,结合上述原理,当树构建SetBlackboard节点时,该node.config_.input_ports和node.config_.output_ports都会添加pair{output_key, OtherGoal},因为output_key是INOUT port。此时,BT的bb的storage_中不会添加对应的entry。直到构建第2个PrintTarget节点时,bb的storage_中才会添加{OtherGoal, Entry}。
为什么代码中tick()是调用setOutput("output_key", value),而不是setOutput(key, value)呢?这里key指通过getInput("output_key", key)获得的值。因为在构建SetBlackboard节点时,output_ports添加的是pair{output_key, xxx},即所有的对应关系、传递线索,是以output_key为准的。对应关系在树构建时就已经确定了,在节点运行tick()时是不会变的,所以tick()中key的值没有发挥作用。
当然,不考虑SubTreePlus(没研究),我认为将SetBlackboard节点的output_key port由INOUT改为仅OUT也是可以的,验证下来也是OK的。
<root main_tree_to_execute = "MainTree" >
<BehaviorTree ID="MainTree">
<Sequence>
<CalculateGoal goal="{GoalPosition}" />
<PrintTarget target="{GoalPosition}" />
<SetBlackboard output_key="OtherGoal" value="-1;3" />
<PrintTarget target="{OtherGoal}" />
</Sequence>
</BehaviorTree>
</root>
行为树之间
以BehaviorTree.CPP/examples/t06_subtree_port_remapping.cpp中的行为树为例。
<root main_tree_to_execute = "MainTree">
<BehaviorTree ID="MainTree">
<Sequence>
<SetBlackboard output_key="move_goal" value="1;2;3" />
<MoveRobot target="move_goal" output="move_result" />
<SaySomething message="{move_result}"/>
</Sequence>
</BehaviorTree>
<BehaviorTree ID="MoveRobot">
<Sequence>
<MoveBase goal="{target}"/>
<SetBlackboard output_key="output" value="666" />
</Sequence>
</BehaviorTree>
</root>
当构建到MoveRobot节点时,识别到它是一个SubTreeNode。当__shared_blackboard=false时,会为该subtree创建一个独立的blackboard(称为子bb),令其parent_bb_成员指针指向父bb(父tree的bb)。并且会在子bb的internal_to_external_中添加重映射{target,move_goal} 和 {output,move_result}。然后递归进入MoveRobot subtree的各节点的构造。
当构建到MoveBase节点时,识别到target是一个bb pointer,但是节点所在树的bb(即子bb)是刚创建的,其storage_容器是空的,此时会调用子bb->setPortInfo()来添加一个Entry。因为子bb的parent_bb_不为空,就要检查子bb的internal_to_external_ 中是否存在target向外的映射。若无,只需在子bb的storage_中添加名为target的Entry;若有,还要在父bb的storage_中添加名为move_goal的Entry。因为subtree的target映射到父树的move_goal。
void Blackboard::setPortInfo(std::string key, const PortInfo& info) {
std::unique_lock<std::mutex> lock(mutex_);
// 有父bb,需要检查是否有向父bb的重映射
if (auto parent = parent_bb_.lock()) {
auto remapping_it = internal_to_external_.find(key);
if (remapping_it != internal_to_external_.end()) {
// 有向父bb的重映射,向父bb传递portinfo
parent->setPortInfo(remapping_it->second, info);
}
}
// 到这,说明父bb为空,或者名为key的port不存在重映射
auto it = storage_.find(key);
if (it == storage_.end()) {
// 本bb无对应entry,使用输入的portinfo构造Entry并保存入storage_
storage_.insert({std::move(key), Entry(info)});
} else {
// 本bb有对应entry,检查数据类型是否匹配,无需更新portinfo,因为创建一次后就不会改变
auto old_type = it->second.port_info.type();
if (old_type && old_type != info.type()) {
throw LogicError(
"Blackboard::set() failed: once declared, the type of a port shall "
"not change. Declared type [",
BT::demangle(old_type), "] != current type [",
BT::demangle(info.type()), "]");
}
}
}
结尾有个小问题,上面是怎么识别到MoveRobot是SubTreeNode呢?
在树的构建过程中,XMLParser::Pimpl::loadDocImpl()会统计xml语句中标签“BehaviorTree”的个数,并将其名称(树的ID)保存在XMLParser::Pimpl的成员变量tree_roots中。
XMLParser::Pimpl::createNodeFromXML()会检查node ID是否在tree_roots中。若在,就标记为subtree node,即type是SUBTREE。
为了理清这些概念、映射间的关系,我在源代码中加了很多log,通过调试不同结构、节点类型的树,来梳理其异同。由此导致代码和打印内容特别啰嗦,我就不在正文中展示了。大家感兴趣的话,可以关注我的知乎专栏 慢慢悠悠小马车 ,搜索同名文章,下载附件(即打印的log),对于理解本文很有帮助。