logo头像
Snippet 博客主题

飞行仿真项目服务端设计

Table of Contents generated with DocToc

一 服务端架构概览

技术选型

nodejs + mongodb,多进程、分布式的服务器。

nodejs优势:

  • io与可伸缩性的优势。io密集型的应用采用node.js是最合适的, 可达到最好的可伸缩性。
  • 多进程单线程的应用架构。node.js天生采用单线程, 使它在处理复杂逻辑的时候无需考虑线程同步、锁、死锁等一系列问题, 减少了很多逻辑错误。 由多进程node.js组成的服务器群是最理想的应用架构之一。
  • 语言优势。使用javascript开发可以实现快速迭代,如果客户端使用html 5,更可实现代码共用。

mongodb优势:

  • 使用简单、方便。不需要预先定义表结构, 和写json配置一样自然。

整体架构

注: 上图中的方块表示进程, 定义上等同于“服务器“

运行架构说明:

  • 客户端登录网关服(gate), 网关服会根据connector服务器群负载情况,动态分配一个返回给客户端。
  • 客户端通过websocket长连接连到connector服务器群。
  • connector负责承载连接,并把请求转发到后端的服务器群。
  • 后端的服务器群主要包括引擎服务器(engine)、验证服务器(auth)等, 这些服务器负责各自的业务逻辑。后面还会有各种其它类型的服务器。
  • 后端服务器处理完逻辑后把结果返回给connector, 再由connector广播回给客户端。
  • master负责统一管理这些服务器,包括各服务器的启动、监控和关闭等功能。

二 通信协议格式

1. 与前端通信

基于tcp/websocket方式通信, 底层使用的是二进制协议。协议包含两层编码: package和message。message层主要实现route压缩和protobuf压缩,message层的编码结果将传递给package层。package层主要实现应用基于二进制协议的握手过程,心跳和数据传输编码,package层的编码结果可以通过tcp,websocket等协议以二进制数据的形式进行传输。message层编码可选,也可替换成其他二进制编码格式,都不影响package层编码和发送。

协议层的结构如下图所示:

Package层

package格式

package分为header和body两部分。header描述package包的类型和包的长度,body则是需要传输的数据内容。具体格式如下:

  • type - package类型,1个byte,取值如下。

    • 0x01: 客户端到服务器的握手请求以及服务器到客户端的握手响应
    • 0x02: 客户端到服务器的握手ack
    • 0x03: 心跳包
    • 0x04: 数据包
    • 0x05: 服务器主动断开连接通知
  • length - body内容长度,3个byte的大端整数,因此最大的包长度为2^24个byte。

  • body - 二进制的传输内容。

握手

握手流程主要提供一个机会,让客户端和服务器在连接建立后,进行一些初始化的数据交换。
握手的内容为utf-8编码的json字符串(不压缩),通过body字段传输。

握手请求:

1
2
3
4
5
6
7
8
9
{
"sys": {
"version": "1.1.1",
"type": "js-websocket"
},
"user": {
// any customized request data
}
}

握手响应:

1
2
3
4
5
6
7
8
9
10
11
{
"code": 200, // response code
"sys": {
"heartbeat": 3, // heartbeat interval in second
"dict": {}, // route dictionary
"protos": {} // protobuf definition data
},
"user": {
// any customized response data
}
}

握手流程如下:

当底层连接建立后,客户端向服务器发起握手请求,并附带必要的数据。服务器检验握手数据后,返回握手响应。如果握手成功,客户端向服务器发送一个握手ack,握手阶段至此成功结束。

心跳

心跳包的length字段为0,body为空。

心跳的流程如下:

服务器可以配置心跳时间间隔。当握手结束后,客户端发起第一个心跳。服务器和客户端收到心跳包后,延迟心跳间隔的时间后再向对方发送一个心跳包。

心跳超时时间为2倍的心跳间隔时间。服务器检测到心跳超时并不会主动断开客户端的连接。客户端检测到心跳超时,可以根据策略选择是否要主动断开连接。

数据

数据包用来在客户端和服务器之间传输数据所用。数据包的body是由上层传下来的任意二进制数据,package层不会对body内容做任何处理。

服务器主动断开

当服务器主动断开客户端连接时(如:踢掉某个在线用户),会先向客户端发送一个控制消息,然后再断开连接。客户端可以通过这个消息来判断是否是服务器主动断开连接的。

Message层

message协议的主要作用是封装消息头,包括route和消息类型两部分,不同的消息类型有着不同的消息头,在消息头里面可能要打入message id(即requestId)和route信息。由于可能会有route压缩,而且对于服务端push的消息,message id为空,对于客户端请求的响应,route为空,因此message的头格式比较复杂。

消息头分为三部分,flag,message id,route。如下图所示:

从上图可以看出,消息头是可变的,会根据具体的消息类型和内容而改变。其中:

  • flag位是必须的,占用一个byte,它决定了后面的消息类型和内容的格式;
  • message id和route则是可选的。其中message id采用varints 128变长编码方式,根据值的大小,长度在0~5byte之间。route则根据消息类型以及内容的大小,长度在0~255byte之间。

标志位flag

flag占用message头的第一个byte,其内容如下:

现在只用到了其中的4个bit,这四个bit包括两部分,占用3个bit的message type字段和占用1个bit的route标识,其中:

  • message type用来标识消息类型,范围为0~7,现在消息共有四类,request,notify,response,push,值的范围是0~3。不同的消息类型有着不同的消息内容,下面会有详细分析。
  • 最后一位的route表示route是否压缩,影响route字段的长度。 这两部分之间相互独立,互不影响。

消息类型

不同类型的消息,对应不同消息头,消息类型通过flag字段的第2-4位来确定,其对应关系以及相应的消息头如下图:

route压缩标志位

route主要分为压缩和未压缩两种,由flag的最后一位(route压缩标志位)指定,当flag中的route标志为0时,表示未压缩的route,为1则表示是压缩route。route字段的编码会依赖flag的这一位,其格式如下图:

上图是不同的flag标志对应的route字段的内容:

  • flag的最后一位为1时,后面跟的是一个uInt16表示的route字典编号,需要通过查询字典来获取route;
  • flag最后一位为0是,后面route则由一个uInt8的byte,用来表示route的字节长度。之后是通过utf8编码后的route字符串,其长度就是前面一位byte的uInt8的值,因此route的长度最大支持256B。

2. 与引擎通信

采用zmq通信,在后台服务器engine上起两个监听端口, 分别是zmq的Publisher模式(25150)和Pull模式(25151)。

服务端向下位机引擎发送消息:

1
2
3
4
5
6
7
// Publisher模式:
// 下位机引擎建立连接时,注册以uid为key的管道
sock.connect("tcp://127.0.0.1:25150")
sock.subscribe("uid")

// 服务端向下位机引擎发送消息
sock.send(["uid", "message"])

下位机引擎向服务端发送消息:

三 用户Gra4工程导入

Gra4格式

主要的几个字段:

1
2
3
UnitGroup: 当前画板模型列表
IoportGroup: 输入/输出模块列表
LineGroup: 连线列表

DB格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
// 工程表 project
{
id: String // 项目ID
uid: String // 用户ID
data: [sub] // 画板数组,每个元素对应一个画板
}

sub: {
id: String // 画板ID
name: String // 画板名称
pid: String // 画板关联的父ID
block: [block] // 画板包含的模型
line: [line] // 画板包含的线
}

block: {
id: String // 模型ID
child: String // 模型关联的子画板ID(即子系统)
modelId: String // 关联导入的数字模型ID (uid_dll_funcName)
name: String // 模型名称
nodeType: Number // 模型类型(方形、圆形)
position: Object // 模型位置 { "x": number, "y": number }
size: Object // 模型大小 { "width": number), "height": number };
items: [{ "id": "out_0", "group": "in" }, { "id": "in_0", "group": "out" }] // 端口描述
entity: String // 切换的实物ID, 默认null
modifyAttr: {} // 修改的属性(和simulink逻辑一致, 先这里取修改的, 没有再去导入数字模型表中取)
}

line: {
id: String // 连线ID
lineType: Number // 连线类型 1: 细线 2: 粗线
source: Object // 线头 {"cell": 模型ID, "port": 端口ID}
target: Object // 线尾 {"cell": 模型ID, "port": 端口ID}
subLine: [] // 粗线详情 [{source: Object, target: Object}]
}

// 示例结构
{
id: string
uid: string
data: [
{
id: string,
pid: string,
name: string,
block: [{
id: string,
child: string,
modelId: string,
name: string,
nodeType: int,
position: Object,
size: Object,
items: [{ "id": "out_0", "group": "in" }, { "id": "in_0", "group": "out" }],
entity: null,
modifyAttr: {}
}],
line: [{
id: string,
lineType: int,
source: Object,
target: Object,
subLine: []
}],
}
]
}

一个项目就是一个树结构,根节点就是顶层画板,子节点是子系统画板,叶子节点是最小的模型。

Gra4转DB整体思路:

  1. 读取解析项目中所有gra4文件,并找出顶层画板gra4对象(根据IoportGroup的Count属性为0判断)。
  2. 从顶层画板开始递归遍历。
  3. 首先确定当前遍历画板的中间模块和连线关系(不考虑Ioport相关模块与连线), 即下图圈起来部分:

  1. 然后确定当前遍历画板的IO模块和连线关系:
  • 遍历与Ioport模块相关连的线,并记录连线端口信息。

  • 然后在当前画板的父画板所有连线中找到与当前子系统连线端口一致的连线。

  • 如果外边连线是粗线(一组线),则需要判断是否展开,如果线连接的是子系统与子系统则不需要展开, 其它都展开。

  • 如果是展开,则每条展开的线对应一个IO模块,以这条线对应的端口做为其ID。然后把连线关系建立起来。

  • 如果不展开,则这条线会对应创建一个IO模块,如果这条线详情大于1条,则还会创建一个总线模块,IO模块ID同样根据线对应的端口确定,总线端口则根据线详情确定。最后把连线关系建立起来。

    转换后的示意图:

下位机引擎生成代码平铺后的格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
{
"PartitionGroup": [
{
"Id": 0,
"CpuId": 0,
"Name": "动力仿真子模型",
"BlockGroup": [
{
"Id": 0,
"Name": "发动机1",
"Model": "Engine/EngineClass.json",
"Order": 0
}
],
"BlcokCount": 5
},
{
"Id": 1,
"CpuId": 1,
"Name": "弹道",
"BlockGroup": [
{
"Id": 5,
"Name": "部段1",
"Model": "Mass/SegmentFunction.json",
"Order": 5
}
],
"BlcokCount": 4
}
],
"LineGroup": [
{
"Src": 0,
"Dst": 1,
"SrcPort": 1,
"DstPort": 0,
"SrcName": "发动机1",
"DstName": "贮箱1"
}
],
"IP": "192.168.0.100",
"PartitionTotal": 2,
"BlockTotal": 9,
"LineTotal": 12
}

DB转换engine平铺格式整体思路:

难点是生成LineGroup连线,其它信息获取比较简单,遍历DB树就能获取。下面主要记录下LineGroup连线生成的思路:

  1. 遍历所有最小模块,找出每个最小模块所有输出的连线。
  2. 然后遍历每条输出连线,递归查找出每条连线最后连的最小模块,即得到一条平铺后的连线。
  3. 递归逻辑:
  • 如果线连的目标是子系统,则进入到子系统里边,依次找到连的目标递归。
  • 如果线连的目标是IO/总线模块,则找到线目标模块递归。
  • 直到线连的目标是最小模块,跳出递归,确定连线。