zl程序教程

您现在的位置是:首页 >  云平台

当前栏目

UDP+有穷自动状态机构造网络指令系统

网络状态自动 UDP 机构
2023-09-11 14:20:53 时间

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秒处。

上面的指令非常容易理解,那么抽象并归纳一下:

每一条指令,由指令前缀、指令体、操作符、参数、指令后缀等元素构成。

上述例子中,指令前缀就是“[”,指令后缀就是“]”,指令体就是“TIME”,操作符就是“+”、“-”、“=”。
为什么要这么搞呢?相信大家都学过《编译原理》,这可是计算机专业的必修课。之所以将指令分成这几个部分,其实就是定义指令系统的词法规则,这样就可以利用有穷自动状态机,很容易的去解析它。

  • 指令前缀和后缀
    其实就是指令的分界符,用于标记一条指令的开始和结束,这类似与C语言中字符串的结束标记“\0”符号,再好比CSV文件中的“,”号,用来分割不同的列。上面例子中,我们分别用“[”和“]”来作为前缀和后缀。由此带来的第一个问题是,我们的指令的其他部分,就不能出现这两个字符了,包括指令体、运算符、还有参数部分,当然,要想解决这个问题也是可以的,那就再引入“转义符”这个概念,比如将连续两个相同的“]”符看做不是指令的结尾,而是本身的符号。为了简单起见,这里不予考虑。因为除了把他们当做分隔符,我们的播放器的指令部分本身,基本上用不到“[”和“]”符号,或者我们约定,指令中不可以出现这两个符号。

  • 指令体
    就是发了什么控制指令,比如TIME,表示定位;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;
	}
}