UDP+有穷自动状态机构造网络指令系统
项目背景
某展厅的小项目,使用Unity制作了一个视频播放器,作为受控端,需要接收解说员手中的“PAD”或“触控屏电脑”等设备发来的控制指令。要求指令系统满足以下功能:
能够随意切换要播放的视频(更换视频URL)
能够控制视频的播放进度(快进x秒、快退x秒、定位于x秒)
能够控制播放器的循环状态(LOOP?)
能够控制视频的播放音量(增加x,减少x,设置为x)
能够控制视频播放器的各类参数,如播放速率、显示模式。
能够查询播放器当前的状态(是否循环,是否正在播放,当期播放速率、当前屏幕拉伸模式)
能够查询当前播放的视频的各类参数,如总时长,当前帧数。
播放器在播放完成等各种情况发生时,能够向注册了事件通知的客户端发送事件通知。
指令系统
如何设计这样的指令系统呢?最简单的方法,就是使用固定形式的定长指令集:建个表,把所有需要用到的指令列出来,每个指令给一个特定的符号,比如,P字符就表示暂停,Y字符表示继续播,再或者0x01表示暂停,0x02表示播放等等。。这种情况下,只要发送这些特定的字符就可以了,好处是指令可以很短,效率可以很高。但是这样会有一些局限,比如你很难携带可变的参数,比如你想将视频的时间定位为1分20秒,那么就得携带1分20秒这个参数过去,这种情况下就会比较麻烦,当然,你可以规定每条指令n个字节,比如第一个字节是指令符,第二个字节到最后一个字节表示参数,这样的话,用一个结构体就可以搞定了。但是每条指令都是定长的,这不容易被扩展,一些不需要参数的指令也存在冗余。
那么,我们就需要一套这样的指令系统:它应该非常容易被解析,指令构造也需要很简洁,能够携带也可以不携带指令的参数,很容易被扩充。
基于上面的考虑,我们尝试构使用字符串造这样一个指令系统:
一上来进行代码解析,理论说明,很容易让人头疼,那先来几个简单的例子:
[TIME+1.5]
这条指令表示,将当前正在播放的视频快进1.5秒。
[TIME-1.5]
这条指令表示,将当前正在播放的视频快退1.5秒。
[TIME=1.5]
这条指令表示,将当前正在播放的视频定位到1.5秒处。
上面的指令非常容易理解,那么抽线并归纳一下:
每一条指令,由指令前缀、指令体、操作符、参数、指令后缀等元素构成。
上述例子中,指令前缀就是“[”,指令后缀就是“]”,指令体就是“SEEK”,操作符就是“+”、“-”、“=”。
为什么要这么搞呢?相信大家都学过《编译原理》,这可是计算机专业的必修课。之所以将指令分成这几个部分,其实就是定义指令系统的词法规则,这样就可以利用有穷自动状态机,很容易的去解析它。
-
指令前缀和后缀
其实就是指令的分界符,用于标记一条指令的开始和结束,这类似与C语言中字符串的结束标记“\0”符号,再好比CSV文件中的“,”号,用来分割不同的列。上面例子中,我们分别用“[”和“]”来作为前缀和后缀。由此带来的第一个问题是,我们的指令的其他部分,就不能出现这两个字符了,包括指令体、运算符、还有参数部分,当然,要想解决这个问题也是可以的,那就再引入“转义符”这个概念,比如将连续两个相同的“]”符看做不是指令的结尾,而是本身的符号。为了简单起见,这里不予考虑。因为除了把他们当做分隔符,我们的播放器的指令部分本身,基本上用不到“[”和“]”符号,或者我们约定,指令中不可以出现这两个符号。 -
指令体
就是发了什么控制指令,比如SEEK
,表示定位;LOOP
, 表示循环状态等等。。。 -
操作符
例子中,我们有三种操作符,分别是:=
表示设置为绝对的值+
表示增加相对的值-
表示减少相对的值
-
参数
表示指令携带的参数数值,根据指令不同,可以是任何数据类型。参数可以被省略,如果参数被省略,则相当于发送了约定的默认值。
更省略的约定:
指令携带的参数如果是默认值,则可以省略。另外,操作符如果是“=”,并且参数也使用默认的话,可以连操作符一同省略,例如:[FRAME=0]
指令中,由于0是缺省值,因此可以省略掉,因此可以写成[FRAME=]
,同时,由于操作符是“=”,则指令可以进一步省略为:[FRAME]
。即:[FRAME=0]
、[FRAME=]
、[FRAME]
三者是等价的。
基于此项目需求的指令列表举例
[TIME=x]
表示,将视频的定位到x秒处。x可以是整数或小数,若省略,默认为0
[TIME+x]
表示,将视频的定位增加x秒,即快进x秒,x可以是整数或小数,若省略,默认为0.1
[TIME-x]
表示,将视频的定位减少x秒,即快退x秒。x可以是整数或小数,若省略,默认为0.1
[FRAME=x]
表示,将视频定位到第x帧。x必须为整数,若省略,默认为0
[FRAME+x]
表示,快进x帧。x必须为整数,若省略,默认为10
[FRAME-x]
表示,快退x帧。x必须为整数,若省略,默认为10
[VOLUME=x]
表示,将音量设置为x。x为0-1之间的小数。若省略,默认为1
[VOLUME+x]
表示,将音量设置增大x。x为0-1之间的小数。若省略,默认为0.1
[VOLUME-x]
表示,将音量设置减小x。x为0-1之间的小数。若省略,默认为0.1
[SPEED=x]
表示,设置播放器的播放速率。x为大于0的小数,默认为1。
[SPEED+x]
表示,增加播放器的播放速率x。默认为0.1。
[SPEED-x]
表示,降低播放器的播放速率x。默认为0.1。
*Time和Frame都可用来定位视频,不同的是Time以时间(秒)为单位,Frame以帧序号为单位。
有一些指令,只有“=”操作符,而没有“+”、“-”操作符,比如:
[URL=x]
设置要播放的视频。x为字符串,如:[URL=d:/movie/demo.mp4]
[LOOP=x]
表示,设置为循环模式,x只能为TRUE或FALSE,忽略大小写。
[EVENT=x]
表示,是否注册事件通知。x为TRUE或FALSE。
[DISPLAY=x]
表示,设置显示模式为x。x为STRETCH、CROP、FIT三者之一,为当视频宽高比和屏幕宽高比不一致时的处理方式:
- Stretch 视频拉伸为全屏
- Crop 裁剪视频以适应屏幕
- Fit 根据屏幕自动适配(留有黑边)。
所有查询参数的指令,也是只有“=”操作符:
[GET=LOOP]
获取当前是否为循环模式。
[GET=URL]
获取当前播放的视频的地址。
[GET=COUNT]
获取当前播放的视频总帧数。
[GET=FRAME]
获取当前播放的帧序列号(第几帧)。
[GET=LENGTH]
获取当前播放的视频的总时长(秒)。
[GET=TIME]
获取当前播放的时间点]
[GET=STATE]
获取当前播放器的状态,返回播放中(PLAY),暂停中(PAUSE),停止中(STOP)三者之一。
[GET=DISPLAY]
获取当前显示模式,返回拉伸(STRETCH)、裁切(CROP)、自动适配(FIT)三者之一。
[GET=EVENT]
获取当前是否注册了事件通知,返回TRUE或FALSE。
当然,还有一些指令是不需要任何运算符和参数的,比如:
[PAUSE]
暂停。
[PLAY]
播放。
[STOP]
停止播放。
[REPLAY]
等价于:[FRAME][PLAY]
当播放事件发生时,播放器会主动向注册了事件通知的所有远端发送事件通知:
[EVENT=LOOPED]
播放完成并开始循环播放。
[EVENT=PREPARE]
播放器准备完成。
如何实现
下面就是重要的实现部分了。
有穷自动状态机构建:
上图标明了利用有穷自动状态机构建指令解析器的状态迁移图,如果能看明白,那就很容易理解了。代码写起来也很简单:
// 定义三种接受状态
private enum ReceiveState
{
Start,
Command,
Params
}
// 定义指令数据,指令,操作符,参数
private struct CommandNode
{
public string cmd;
public char opr;
public string par;
}
// 线程安全的指令队列
private readonly ConcurrentQueue<CommandNode> commands = new ConcurrentQueue<CommandNode>();
// 有穷自动状态机解析收到的串
private void OnReceiveString(string cmd, IPEndPoint remote)
{
// 获取远程接收上下文相关的接收状态、指令缓冲、操作符、参数缓冲。
ReceiveState state = GetRemoteState(remote);
StringBuilder currPar = GetRemoteCommandBuffer(remote);
char currOp = GetRemoteOperator(remote);
StringBuilder currPar = GetRemoteParamsBuffer(remote);
// 遍历收到的串。此处已考虑粘包、拆包情况。
foreach (var ch in cmd)
{
switch (state)
{
case ReceiveState.Start: // 开始状态
if (ch == '[') // 如果是前缀,清空指令,迁移到接收指令状态
{
currCmd.Clear();
state = ReceiveState.Command;
}
break;
case ReceiveState.Command: // 接收指令状态
switch (ch)
{
case ']': // 如果遇到后缀符,表示收到无操作符,无参数的指令,压入队列。
{
if (currCmd.Length > 0)
{
commands.Enqueue(new CommandNode()
{
cmd = currCmd.ToString(),
opr = '\0',
par = null
});
}
state = ReceiveState.Start; // 迁移状态,重新开始解析
break;
}
case '=': // 如果遇到=、+、-字符,记录作为操作符,并迁移到参数接收状态。
case '+':
case '-':
currOp = ch;
currPar.Clear();
state = ReceiveState.Params;
break;
default: // 遇到其他字符
{
// 如果长度未超限制,并且字母或数字,记录指令
if (currCmd.Length < 1024 && char.IsLetterOrDigit(ch))
currCmd.Append(ch);
else
state = ReceiveState.Start; // 非法字符或长度超限,迁移状态,重新开始解析
break;
}
}
break;
case ReceiveState.Params: // 接收参数状态
if (ch == ']') // 遇到后缀,指令结束。将指令、操作符、参数压入队列。
{
if (currCmd.Length > 0)
{
commands.Enqueue(new CommandNode()
{
cmd = currCmd.ToString(),
opr = currOp,
par = currPar.ToString()
});
state = ReceiveState.Start;
}
}
else if ( currPar.Length < 1024 )
currPar.Append(ch); // 参数未超长度限制,记录参数
else
state = ReceiveState.Start; // 长度超限,迁移状态,重新开始解析
break;
}
}
}
// 定义指令处理系统
public delegate void OnCommandHander(string cmd, char op, string pars);
public event OnCommandHander OnCommand;
// MonoBehaviour 每帧处理收到的指令,之所以不在接收函数中处理,是因为,通信采用异步方式,处理接受的线程并不是主线程,
// 但在unity中,使用主线程来更新游戏物体引擎相关数据。因此采用指令队列的方式解决。
private void Update()
{
if (commands.TryDequeue(out CommandNode node))
{
OnCommand?.Invoke(node.cmd, node.opr, node.par);
}
}
有了上面的指令解析系统,就可以定义播放的指令解析行为,例如:
private void Awake()
{
network.OnCommand += OnReceiveCommand;
}
// 接收到了指令处理。指令为cmd,操作符为op,无操作符则为'\0', pars为收到的参数,无则为null
private void OnReceiveCommand(string cmd, char op, string pars)
{
switch( cmd )
{
// 处理TIME设置 的例子。
case "TIME":
float time = 0;
if( ! string.IsNullOrWhiteSpace(pars))
{
if(!float.TryParser( pars, out time ))
break;
}
switch( op )
{
case '\0':
case '=':
player.SetTime( time );
break;
case '+':
player.SetTime( player.GetTime() + time );
break;
case '-':
player.SetTime( player.GetTime() - time );
break;
}
break;
}
}