命令处理一般流程#
本项目以 Linux Bash 和 Huawei VRP 为例,推测 VRP CLI 可能的实现方法
流程简图#
目标#
在操作系统中,命令的处理和下发单独由一个模块进行处理。无论是命令行(CLI)和图形界面(GUI)都负责接受用户输入,并将用户输入传递给对应的功能模块。命令处理中不应该直接干涉功能,并且能够合理处理用户的复杂输入(例如脚本语言,命令补全),传递给多个模块协同运行。
通用操作系统的命令处理#
以 Linux Bash 为例#
通用操作系统的特点是个体间差异性较大,用户输入和使用场景较为复杂,面临对应功能模块的不确定性较大。因此,通用OS的命令行处理需要更多的考虑模块化和健壮性,对外提供合理的安装注册接口,对内预设各种可能的错误和异常情况
Bash 师出 UNIX sh ,在 Linux 中,应用程序通常以可执行文件的形式存在,而在 bash 识别过程中,会先判断键入命令是否是别名,如果是就进行替换,然后检查是否是bash的内部命令,例如cd
、exit
等。当然,内部命令也分为特殊内部命令,shell函数和普通内部命令,这部分负责脚本的识别和调整 Bash 自身的状态。 接着搜索PATH中指定的环境变量,查找与输入匹配的可执行文件。
在 Linux 中,Bash 的搜索路径由环境变量 PATH 决定。 Bash 通过搜索这些可执行文件,将其余的命令参数传递给实现功能的模块。Bash 还有很多配置文件,由于历史原因,兼容性问题,分级管理设计,灵活性设计等, Bash 通常逐级读取系统级配置/etc/profile
,用户级配置 ~/.bashrc
和其他层级的配置文件。
通过以上设计,我们可以发现 Bash 是与功能模块解耦的,安装功能只需要添加对应的搜索路径或者将二进制程序移动到搜索路径中即可。 Bash 本身也通过 fork-exec 等方式唤起其他可执行程序,保证自身的健壮性。但是,这样的设计无法天然支持对某个模块参数的补全,也无法进行模糊匹配,因为无法确定具体的可执行文件,将参数传递。在效率上 ,尽管 Bash 通过哈希等方式尝试加速曾经执行过命令的查找,也无法确保在命令类型/搜索路径中可执行文件数量较大,或是冷执行时的效率。
可以使用如下脚本在 /usr/bin下生成5万个可执行文件
#!/bin/bash
for i in $(seq 1 50000); do
echo -e "#!/bin/bash\necho \"This is file $i\"" > file_$i.sh
chmod +x file_$i.sh
done
echo "Generated."
然后试试按Tab的响应速度,如果是对 file_
试图补全的话,大概有1秒左右的延迟。
正好最近在搞 huawei 的交换机,下面就以其网络操作系统为例介绍定制下的设计方案。
定制操作系统的命令处理-以 Huawei VRP 设计为参考#
设计目标#
前面说明了通用操作系统的特点"解耦合,模块化,变化多",那么定制操作系统则恰好与之相反,通常系统不会有太大的变化,但是本身具有相当程度的复杂性,需要命令能够快速查找和下发。所以,定制操作系统的CLI通常作为系统的某个核心模块进行编程开发,与其他模块之间有更高效的通信和协作方式。
特点#
- 命令分类视图化
支持不完整输入
支持命令帮助和补全
- 支持立即下发模式和延迟生效模式
其中,支持不完全输入和命令帮助是定制系统的重要功能。
[Huawei]interface Ethernet0/0/0
[Huawei-Ethernet0/0/0]ip address 192.168.57.10 24
[Huawei-Ethernet0/0/0]i a 192.168.57.11 24
模型设计: 命令树#
(推测结果,不代表实际代码,我也不是 Huawei 员工)
举例说明:
我们以最简单命令树模型进行举例,上面这张图表示了某个视图下命令 settings apply 和 ip { address | mask } <IP> 的逻辑结构。 注意,这里仅代表token的逻辑结构,在命令树或内存中的实际表现可能有一定的出入。命令行格式和基本情况可以参考 Huawei 的命令手册 和 使用指南
定义命令#
对于交换机系统中的每个模块,可以定义该模块所需的所有token(参数),例如模块需要 settings apply 和 ip { address | mask } <IP> 两条命令,即定义6个token。 这些token按一定的顺序组成一条完整的命令
p[3] = new Token("ip",modeID)
p[4] = new Token("address",modeID)
p[5] = new Token("mask",modeID)
p[6] = new IPclass
cmd2.head = p[3]
b[1] = new branch(p[4],p[5])
p[3].addNext(b[1])
p[4].addNext(p[6])
p[5].addNext(p[6])
当然,设计系统的时候可以考虑提供更高等级的封装,将线索的具体解析移动到相应的模块处理,例如下面这种方法:
cmd2.set("p3 { p4 | p5 } p6")
/*
* 然后函数调用相应模块生成命令树
* 这部分工作在初始化时完成,线索处理模块可以使用自动机完成(类似于编译器)
*/
设置属性,合并命令子树#
如果命令在现在和将来都不多的情况下,可以考虑使用两棵命令树
对于每一个元素,可以添加帮助函数,在帮助操作命中该元素时,可以根据给定的前项元素给出提示输出 (如果元素的帮助含义唯一,也可以不需要识别帮助前项元素的来源)
p[3] = new Token("ip",modeID,"helpIP")
p[4] = new Token("address",modeID,addressHelpInfo)
getHelp(vector UserInput){
if(UserInput.empty())
return this->helpInfo[0];
switch(vector.end()-1){
case "ip": return this->helpInfo[1];
}
}
为了区分命令对延迟生效模式和立即生效模式的支持程度,可以使用标记,并添加分支节点的数据结构
请注意,在区别延迟生效模式和立即生效模式时,在后续的命令树合并过程中可能出现问题,需要提前设计好数据结构。 以下是举例说明
- 命令 A B C 支持延迟生效
- 命令 A B D 不支持
- 命令 E F D 支持延迟生效graph LR id1(A)-->id2(B)-->id3(C) id4(E)-->id5(F)-->id6(D) id7(A)-->id8(B)-->id9(D) style id1 fill:#090,stroke:#333,stroke-width:4px style id2 fill:#090,stroke:#333,stroke-width:4px style id3 fill:#090,stroke:#333,stroke-width:4px style id4 fill:#090,stroke:#333,stroke-width:4px style id5 fill:#090,stroke:#333,stroke-width:4px style id6 fill:#090,stroke:#333,stroke-width:4px style id7 fill:#990,stroke:#333,stroke-width:4px style id8 fill:#990,stroke:#333,stroke-width:4px style id9 fill:#990,stroke:#333,stroke-width:4px
合并后,节点D的属性产生冲突
再引入一条命令 A B D G
记住来源
以免判断错误。或是采用染色法,复用命令元素,但是命令的跟踪路径染色在后续的匹配过程中,待过滤元素需要和上一级保持一致。有多种元素的话,需要刷新为和上一级一致的唯一颜色(激活法)。
另外也可以采用只合并公共前缀,不合并后缀的方法。这样确定的子树就是唯一的。不过内存的消耗会更大。对于上面的情况,可以改写为:
例如对 D 进行帮助操作的时候会需要判断是否显示 G 但是 G 本身是一个分支节点,就需要继续向后查询,直到判断出某个具体的分支。
这里就需要注意了,向后查询确实能够保证搜索结果的正确性,但是会大大降低搜索效率。如果某个分支的子分支非常多,子节点层数深,那么搜索将会耗费大量的时间,还有可能搜索到最后结果也为空。
对于这种情况,一个解决办法是在初始化命令树时,对可能存在延迟生效属性的分支进行染色,相当于通过颜色在逻辑上区分不同环境下的命令树,而在实际内存中复用同一棵命令树。具体是实现是在合并命令树时,递归地染色挂载点及其父级分支节点。在检索时,如果发现该节点被染色,说明其分支中存在支持延迟生效模式的命令元素组合,深入搜索;否则放弃整棵子树。
挂载完整命令树#
在定义完元素,设计好命令之后,CLI在初始化命令树时,各个模块向CLI注册自己的命令行,即CLI根据配置文件调用或各模块自行初始化。
// 某功能模块
class Mod{
public:
Token** p = new Token*[10]
Mod(){
p[3] = new Token("ip",modeID)
p[4] = new Token("address",modeID)
p[5] = new Token("mask",modeID)
p[6] = new IPclass
p[7] = new IPv6class
cmd1.set("p3 { p4 | p5 } p6")
cmd2.set("p3 { p4 | p5 } p7")
}
}
每个模块拥有其自身的命令子树,每个视图下拥有多个模块,形成该视图下的命令树
当然,具体的进入命令实现需要依赖我们后面设计的命令检索与实现。
命令树的检索、自动补全#
当命令树构建完全后,需要处理用户输入来找到正确的命令路径,这里我们以只合并公共前缀,使用染色方法区分模式的,命令树说明检索的实现
假设用户输入为 E F D G I,CLI将会接受这一段输入,拆分为四个元素,向末尾添加<end>,依次检索
取第一个输入 E 匹配该视图的根节点,将 E 加入过滤器
取第二个输入 F 匹配过滤器中 E 的下级节点,二级过滤器中加入 F
取第三个输入 D 匹配过滤器中 F 的下级节点,三级过滤器中加入 D
当然,这里的多级过滤器在实际中可以设计为一个队列复用。
取第四个输入 G 匹配过滤器中 D 的某一下级节点,丢弃子节点H,四级过滤器中加入 G
取第五个输入 I 匹配过滤器中 G 的某一下级节点,丢弃子节点<end>,五级过滤器中加入 G
取第六个输入 <end> 匹配过滤器中 I 下级节点,六级过滤器中加入<end>
输入队列为空,过滤器结果合法,匹配完成
此时可以调用每特殊元素的检查函数,例如IPv6class
的输入IPv6是否合法,字符串是否超过最大长度等。对于延迟生效模式,只需要增加对染色的判断即可。
帮助的实现与之类似,输入队列为空后,依次输入过滤器中元素对应的帮助信息即可,自动补全实现类似,不再赘述。
如果过滤器为空或出现非法情况,返回当前队列位置处出错即可。
命令消息传递#
当命令树完成对用户输入的匹配后,需要将命令消息传递给相应的模块进行处理。通过匹配的最终元素内的ID确定该条命令应该被传送到视图下的哪一个模块。请注意。这里有一个问题,如果两个模块注册了“相同”的命令元素(指字符内容相同,但是模块ID不同)可以有两种处理办法:
第一种是禁止相同的公共前缀(例如首元素必须不同),这样能确保命令一开始就会在正确的子树中匹配;第二种是合并所有相同的前缀字符串,并将其ID设置为公共,所传送的模块由最后一个匹配的命令元素模块ID确定。
确定完传递给哪一个模块后,CLI只需将匹配到的命令以元素ID组的形式发给功能模块,由功能模块自行根据含命令元素ID的消息进行处理。
其他#
因为命令树的初始化是一个较为耗时的工作,可以将命令树作为守护进程使用,前端进程负责与其通信或者与其的fork子进程通信。模块的具体使用可以用父子进程消息传递,也可以直接使用函数堆栈的设计方案。具体操作例如会话,进程组,字符设备,伪终端等概念在抽象的方案设计中就不再详述。
其他模型设计: Bash-redis#
除了将 CLI 与系统整合在一起外,还可以选择将CLI分离得更多一些。利用现有组件,可以采取 Bash + redis 的方法来组合解析命令树。
其中,Bash可以结合shell以及python脚本实现对命令的分类和解析,将内存中的命令树转化为文件系统的目录树
,视图作为文件夹,帮助和依赖脚本和python实现(脚本和python可以又由其他工具自动生成)。其他功能模块打包为可执行文件,结合搜索的环境变量位置,实现对命令的分类处理。对外可以屏蔽本文件系统,也使用类似 VRP 的 shell。
系统内部通信使用内存数据库(以redis为例),可以将参数和系统状态都放入内存数据库中,各个模块自行从中读取,消息的传递可以借由进程间通信,也可以使用内存数据库作为消息总线,不过这样的风险可能是速度较慢,需要合理规划,数据平面和控制平面分开处理。 这样的设计还有利于分布式和高可用,服务挂掉只需要重启某一个容器即可。