GBT 20999-2017 SDK 程序设计机制和技巧说明
索引
- 1. API应用机制
- 2. 配置参数机制
- 3. 结果池设计机制
- 4. 数据转义机制
- 5. 数据解义机制
- 6. 数据自动粘包半包
- 7. 客户端容器机制
- 8. 数据自动分帧
- 9. 数据自动校验
- 10. 数据消息推送器
- 11. 交易跟踪事务容器机制
- 12. 通讯过程总体预览
1. API应用机制
在SDK程序中API的文件目录位置,该目录下的类是直接对外部引用SDK的程序直接使用,本类中提供的方法全是静态工具方法,可直接使用。对外提供的我们常用的动作方法函数,如:启动SDK的TCP服务,上位机程序引用SDK,就说明上位机的应用(JAVA的WEB应用或JAVA的客户端应用)就拥有了SDK的全部能力。
开发者在依赖GBT20999-2017
的SDK程序后,可以在自己项目中直接调用SDK程序中的API方法,从而实现与SDK程序交互。基本的参数查询、设置、下位机的中心控制动作和命令管道控制动作等等。都可以直接使用,同时还可以从这调用其它设计容器的方法,比如客户端连接容器信息等等。建议:开发者在二次开发或使用源码开发功能时,对外暴露使用的方法统一在API层的类中编写,这样避免了API层和业务层之间的耦合。
机制示意如下图所示:
2. 配置参数机制
SDK程序有自己的配置参数,这些配置参数在
ConfigParam.java
类里定义,参数有各默认值,用户可以根据自身需求进行修改。为了给用户更灵活的配置方式,提供了LoadConfig.java
工具类,开发者用户可以通过LoadConfig.java
工具类来加载配置参数。
配置参数加载方式目前有两种,在resources
目录下找到gbt20999_2017_sdkconfig.properties
属性配置文件,开发者可以在此文件中修改参数;另一种方式通过LoadConfig.java
工具类,目前使用到的配置参数并不多,开发者可自行查看源码。下图是笔者开发的WEB网关程序的示意截图。
建议:开发者在自己项目中也增加配置参数页面,将配置参数保存在数据库中,通过数据库查询参数,实现动态配置参数。然后项目在启动时,将数据库中的参数加载到内存中,项目运行时,直接从内存中获取参数。这样,项目在运行时,参数的修改,不会影响项目运行。
加载配置参数示例代码:
/**
* 加载配置到SDK
*
* 【开发者说明】
* 开发者可以在自己程序服务启动时,调用此方法。加载配置参数后,即可调用启动TCP的服务方法了。
*
* 【使用场景】
* 服务启动时,SDK配置参数会自动加载到SDK中,开发者不需要再调用此方法。
*/
public void loadSDKConfigParam() {
SDKConfigParam sdkConfigParam = getDataByWhereFirst("");
if(sdkConfigParam == null) return;
/*
* 为了适合SDK的加载方法,使用Properties对象
*/
Properties properties = new Properties();
properties.setProperty("CommonParam.CTRL_CENTER_ID",String.valueOf(sdkConfigParam.getCtrlCenterID()));
properties.setProperty("CommonParam.BYTE_BUFFER_LEN",String.valueOf(sdkConfigParam.getByteBufferLen()));
properties.setProperty("CommonParam.IS_DEV_MODE",String.valueOf(sdkConfigParam.getIsDevMode()));
properties.setProperty("CommonParam.CHARTSET",String.valueOf(sdkConfigParam.getChartset()));
properties.setProperty("CommonParam.USE_SET_TRANSACTION",String.valueOf(sdkConfigParam.getUseSetTransaction()));
properties.setProperty("ConnectParam.CLIENT_PUSH_STATUS_FREQUENCY",String.valueOf(sdkConfigParam.getClientPushStatusFrequency()));
properties.setProperty("ConnectParam.CLIENT_HEART_FREQUENCY",String.valueOf(sdkConfigParam.getClientHeartFrequency()));
properties.setProperty("ConnectParam.TCP_SERVER_PORT",String.valueOf(sdkConfigParam.getTcpServerPort()));
properties.setProperty("ConnectParam.UDP_SERVER_PORT",String.valueOf(sdkConfigParam.getUdpServerPort()));
properties.setProperty("ConnectParam.HEART_SENSITIVE_TIMES",String.valueOf(sdkConfigParam.getHeartSensitiveTimes()));
properties.setProperty("ConnectParam.CHECK_CLIENT_ID_EXIST",String.valueOf(sdkConfigParam.getCheckClientIdExist()));
properties.setProperty("ConnectParam.AUTO_CLEAN_ZOMBIE_CONNECTION",String.valueOf(sdkConfigParam.getAutoCleanZombieConnection()));
properties.setProperty("ConnectParam.QUERY_HEART_FREQUENCY",String.valueOf(sdkConfigParam.getQueryHeartFrequency()));
properties.setProperty("ConnectParam.CLEAN_ZOMBIE_FREQUENCY",String.valueOf(sdkConfigParam.getCleanZombieFrequency()));
properties.setProperty("MsgParam.PUSH_CLASS_FULLPATH",sdkConfigParam.getPushClassFullPath());
properties.setProperty("MsgParam.PUSH_METHOD_NAME",sdkConfigParam.getPushMethodName());
properties.setProperty("RUN_STATUS_MODE",String.valueOf(sdkConfigParam.getRunStatusMode()));
// 这一行就是直接调用SDK程序的自动加载配置参数的方法
LoadConfig.autoLoadConfigParam(properties);
}
3. 结果池设计机制
SDK程序对下位机的每一个动作(查询、设置)都应该有一个对应的响应结果数据返回,因为SDK程序与下位机是TCP连接,数据交互交不是阻塞式交互的,而是异步的线程,所以,SDK在设计时,设计了一个中间缓冲区容器池,笔者称之为
结果池机制
。在对下位机的动作是一个完整的数据帧结构,该结构要参照GBT20999-2017标准通讯协议
。每一次动作都有一个独立的数据帧索引值
、动作类型值
、信号机ID值
组合成一个唯一动作标识KEY值,下位机在接收到数据时,会向上位机做相应的数据应答。
在结果池容器机制中,每一个动作都对应一个WaitResultData.java
的对象,查询、设置分别对应着各自的两种数据帧类型,即正确类型、错误类型。动作映射类型在ResultPoolContainer.java
类中frameTypeMap
对象做了映射。因此增加了这样机制就使动作仿佛有了同步等待应答结果的效果。示意机制如下图所示:
4. 数据转义机制
根据国标规范文件的要求,数据在传输时,如果数据除帧头和帧尾外,内容中若有
7E
与7D
,则需要转义,转义规则要求在文件的附录A
中的A.1通信帧结构
里b)
项有明确的描述说明:协议约定开始字节为0x7E,结束字节为0x7D,转义字符为0x5C,在报文数据中,遇到开始字节、结束字节、转义字符,在其前增加转义字符0x5C
;
在SDK程序的代码中,笔者定义了CustomByteToMessageEncoder.java (Netty框架自定义编码器)
的类,在本类中,调用了dataFrameInfo.convert_7E_7D_ByteBuf();
数据帧对象的转义动作。
数据示例如下:
查询示例:7e 00 13 00 01 01 00 00 00 01 01 10 10 01 01 04 05 02 02 7e 8f 2f 7d
。该数据帧就是我们要发送查询的指令,在数据体中出现了7e
,所以在程序发送时,需要将7e
替换为5c 7e
。即程序发送的内容应该为:7e 00 13 00 01 01 00 00 00 01 01 10 10 01 01 04 05 02 02 5c(此处增加转义字节) 7e 8f 2f 7d
。同理,下位机在返回数据时,也需要将7e
或7d
替换为5c 7e
或5c 7d
。所以,上位机SDK程序还需要解义数据,即数据解码动作。
5. 数据解义机制
SDK程序在接收到字节数据时,也会自动地识别数据体内是否有
5c 7d
或5c 7e
需要解义。因为下位机发送上来的数据很有可能带有5c 7d
或5c 7e
。需要注意的是,标准文档中约定,转义字节是不参与帧校验字节计算的。
在SDK程序中,笔者定义了CustomByteToMessageDecoder.java (Netty框架自定义解码器)
的类,在本类中,调用了GBT20999ByteUtil.readConvertTrans(byteStr);
数据帧对象的解义动作。
6. 数据自动粘包半包
在GB/T 20999-2017中,数据帧的起始字节为
7e
,结束字节为7e
,所以,在GB/T 20999-2017中,数据自动粘包半包功能是必须的。那么在本SDK程序中,CustomByteToMessageDecoder.java (Netty框架自定义解码器)
的解码器中,也对数据字节粘包和半包进行了拆分和暂时缓存的处理。若一个数据包在约定的缓冲区大小内,是多个数据帧构成粘包现象,示例如下:
7e 01 02 01 .... 7d 7e 02 03 04 ... 7d 7e 02 03 04 ... 7d
,像这样的数据包,在GB/T 20999-2017中,是多个数据帧构成粘包现象。解码器需要将这样的数据包进行拆分,并构建成多个DataFrameInfo的对象。
若数据是半包现象,示例如下:
7e 01 02 03 ...... 04
这样部分数据,然后下一帧数据是05 02 ..... 7d
,其实完整的数据,应该是这两个数据包组合而成的,所以SDK已经将这样的数据也进行了临时缓存并数据校验的处理了。
所以,在SDK程序中,CustomByteToMessageDecoder.java (Netty框架自定义解码器)
的解码器,先是做了数据拆包、数据解义、数据帧校验比对的动作,然后再将数据进行数据帧对象构建。再自动判断本次数据的帧类型是查询应答、设置应答、主动上报等将设备解析后存入至结果池中。
7. 客户端容器机制
SDK程序有下位机客户端管理的容器,管理的客户端都是在线的下位机设备连接信息,开发者可以调用
GBT209992017API.getClients()
查看当前在线设备的对象信息。但是,SDK容器的对象,并不能代表开发者自己程序设备的对象,因为开发者自己程序的设备是要存在在数据库中的,可以与SDK管理容器结合使用,创建自己程序的容器,可以识别设备的工作状态。
SDK程序中的客户端容器,拥有自动清理僵尸连接、收集集体心跳两种机制。开发者可以看ClientContainer.java
的代码,了解容器的机制。
7.1. 自动清理僵尸连接
所谓僵尸连接指的是,下位机通过TCP与SDK建立连接,但是在规定时间定没有任何通讯数据,SDK将判断这种连接为僵尸连接,并清理掉。所以强烈建议,下位机在连接SDK程序的TCP服务时,最好主动向上发送一条数据包,只要下位机主动发送了一条
主动上报
类型的数据后,SDK程序便可以得知该下位机是活跃的同时还可以获取到该下位机的逻辑ID值,当SDK得知下位机的ID信息后,便可以将该下位机的信息存入自己的容器中,同时集体心跳将激活,所有网络在线正常的下位机设备连接,都将参与集体心跳。SDK通过集体心跳的查询,自行逻辑判断下位信号机设备是否在线。若下位机与SDK建立连接没有立即发送一条主动数据包,若集成程序也没有在约定时间内调用SDK的设置信号机ID方法,那么这个连接很可能会被识别成僵尸连接,最终被干掉。
开发者可以参考ClientContainer.AutoCleanZombieConnectionThread.java(内部线程类)
,在线程类中,针对当前容器内的客户端连接进行检查,查看每个设备的信号机ID值是否为空,若在30秒内,该设备仍为空,那么将此连接删除。间隔时间变更为ConfigParam.ConnectParam.CLEAN_ZOMBIE_FREQUENCY
,开发者可以根据需要自行修改。
7.2. 在线设备集体心跳
国标GBT20999-2017文件中,上位机对下位机是有单独的心跳查询与应答机制的,在SDK程序中,默认是每60秒查询一次当前所有网络在线的设备,使得心跳查询只在一个线程内去完成即可,没有必要在每个设备的线程去查询,避免资源浪费。集体心跳的类代码参考
ClientContainer.CollectiveHeartThread.java(内部线程类)
,SDK仅仅对网络在线通讯正常的设备批量发送心跳查检动作。每次查询之后,都会将每个设备的查询结果存放到ClientContainer类
的属性变量collectiveHeartArray
中,然后通过数据消息推送器,将查询结果推送到集成SDK的程序中。部分代码示例:
while(true) {
ThreadUtil.sleep(ConfigParam.ConnectParam.QUERY_HEART_FREQUENCY);
collectiveHeartArray.clear();
Iterator<Map.Entry<String, ClientInfo>> iter = connectContainerMap.entrySet().iterator();
while(iter.hasNext()) {
Map.Entry<String, ClientInfo> entry = iter.next();
ClientInfo tempCi = entry.getValue();
// 如果下位机ID值为空
if(tempCi.getClientSigID() == null) continue;
// 执行心跳查询
tempCi.queryHeart();
collectiveHeartArray.add(tempCi);
}
JSONObject msgJo = PackageJSONHeadBodyKit.get(CommonConstant.DataTypeConstant.DATATYPE_COLLECTIVE_HEART,
"集体心跳查询结果",collectiveHeartArray,
"SDK 客户端集体心跳查询");
PushMsgUtil.pushData(msgJo);
}
特别提醒,这里的集体心跳,并不是查询下位机运行状态的数据(如,运行哪个阶段、各灯组通道倒计时等),有些开发者会将这两者弄混淆,SDK程序已经将这两者进行了区分了,集体心跳就是最单纯地查询下位机网络状态,而下位机各参数运行状态,在SDK中定义的叫自定义运行状态
的机制。
8. 数据自动分帧
自动分帧机制指的是在数据通用式查询或设置的时候,开发者可以不用考虑下位机数据内存缓存区字节大小,随意拼装动态的数据指令对象集合,SDK会自动将所有的查询、设置指令根据SDK配置的缓冲区大小进行自动分帧多个DataFrameInfo对象处理。例如:下位机的缓冲区大小是4096KB,某一次的查询动作,所有查询指令和数据帧体最终大小超过了4096KB,可能是5100KB时,那么SDK将会自动分成两个数据帧进行动作查询,最终将这两个查询动作合并成一上结果对象返回。
开发者可以查看GBT209992017API.queryInstructs
方法,即可得知SDK程序如何自动将数据进行自动分帧处理。创建了数据帧的前世类PrelifeDataFrames.java
的对象,示例代码如下所示:
// 构建数据帧的前世对象
PrelifeDataFrames pdf = new PrelifeDataFrames(sigID);
// 将所有数据值集合全部投射至前世对象中,让前世对象自动对这些数据值进行判断是否分帧
pdf.setDataFrameValueListByInstructs(instructsList);
// 将所有数据值对象与数据帧组合使其转世为全新的待发送的数据帧对象
List<DataFrameInfo> reincarnates = pdf.reincarnate();
调用了reincarnate()
方法,将所有的指令转换成List<DataFrameInfo> reincarnates
对象,这就是自动分帧的基本设计思路。
9. 数据自动校验
在接收下位机TCP连接发送的每一帧数据,都需要对数据进行校验,一是验证数据的完整性,二是验证数据是否被篡改,三是预防恶意数据攻击。在接收到一个完整帧时,SDK会根据字节数据从帧头到帧尾重新计算一下校验码字节结果,再与接收到的数据帧中的校验码字节数据进行对比,若对比一致,则数据校验通过,若对比失败,则丢弃数据不给予后续处理。核心代码
CustomByteToMessageDecoder.java
类文件里,如下:
/*
* 检查数据帧校验值是否正确
*/
int getCalValCode = CRC16Util.crc_16_hasAll(s); // 利用算法获得数据校验码
int curFrameValCode = GBT20999ByteUtil.getCRCVal(new byte[]{s[s.length - 3], s[s.length - 2]});
if(getCalValCode != curFrameValCode) {
LoggerKit.error(this.getClass(), "数据帧校验值匹配不一致,计算帧校验值:["+getCalValCode+"],当前帧校验值:["+curFrameValCode+"],字节数据串:["+convertStr+"]");
// 表示最后一个字节是转义字节,则不进行丢弃,此处可能分包了,例如:7e xx xx xx xx 5c 7d,但是这个数据可能不是真正完整数据,末尾可能还有一个字节被TCP给分包发送了,后面可能会接收到一个7d字节,最终完整数据应该是:7e xx xx xx xx 5c 7d 7d
if(s[s.length - 2] == 0x5c && s[s.length-1] == 0x7d) {
LoggerKit.info(this.getClass(), "数据帧校验值匹配不一致,疑似数据以5C 7D结尾且完整数据被分包!当前字节数据串:["+convertStr+"]");
GBT20999ByteUtil.putTempBufferFocus(tempBuffer, bytes);
}
return;
}
10. 数据消息推送器
数据消息推送器是SDK中非常重要的机制,因为开发者的程序要依赖SDK的JAR程序,所以开发者的程序就拥有SDK的功能。所以调用SDK程序中的方法是很容易的,但是,SDK若想要把数据推送回开发者自己的程序,则无法直接调用。所以,笔者使用了JAVA的反射机制,设计了数据推送回开发者程序的功能,其本质就是利用JAVA反射调用配置中指定的类和静态方法中。这样,开发者的程序就可能通过这个静态方法接收到SDK推回的所有数据了,例如:SDK会向集成程序推送心跳数据、各种动作数据、事件、告警数据等等。开发者亦可自行再定义推送回的数据类型。 开发者可以查看
PushMsgUtil.java
类,在SDK中,笔者默认预设了一个模拟接收类,开发者可以仿照笔者的代码,自行定义接收类,并实现相应的静态方法。ImitateReceive.java
,这样,各位开发小伙伴们就可以在自己程序上接收到SDK的数据了。推送器核心代码如下:
Class<?> threadClazz;
Method method;
try {
threadClazz = Class.forName(ConfigParam.MsgParam.PUSH_CLASS_FULLPATH);
method = threadClazz.getMethod(ConfigParam.MsgParam.PUSH_METHOD_NAME, JSONObject.class);
method.invoke(null, pushParam);
} catch (Exception e) {
e.printStackTrace();
}
设计思路如下图所示:
小建议:开发者在自己的自定的类方法中,最好使用生消模式
以免影响SDK程序的运行性能。因为SDK可能会推送大量的数据给开发者的程序中,开发者还需要针对不同的数据类型,进行各自的业务后续逻辑处理。如果直接调用业务逻辑代码,可能会因逻辑代码处理效率导致SDK程序运行效率下降。当然SDK程序本身已经使用了线程池来做数据推送,这也是在推送中做了一个小缓冲作用。
11. 交易跟踪事务容器机制
交易跟踪是GBT20999-2017标准文档中高级检测项里的要求,根据指引文件里里需要参与事务的OID列表,将OID列表中的OID进行分组,并生成对应的事务容器。所以在设置参数时,SDK程序是将所有设置指令对象分为两个大组,一个是非事务组(常用集合),另一个是参与事务组(事务集合)。
一个完整的事务周期包括:【初始事务】、【开始事务】、(设置数据是中间过程)...、【审核事务】、【完成事务】。开发者可以看TradeFollow.java
类观察事务执行的过程。在下面的通讯过程总体预览
里可以看到设置参数时交易跟踪事务
所处的位置过程。
12. 通讯过程总体预览
数据通讯过程可以分为两大类,即查询、设置类(上位机 -> 下位机);主动上报类(下位机 -> 上位机)。
数据查询设置类型动作:上位机将数据帧拼装成数据报文,通过TCP网络连接将数字以16进制字节形式发给下位机。然后下位机解析数据报文,并返回相应的查询应答或设置应答结果报文。
数据主动上报类型动作:下位机将数据帧拼装成数据报文,通过TCP网络连接将数字以16进制字节形式发给上位机。然后上位机解析数据报文,SDK程序再继续将数据解析或封装再转给集成的程序指定接收类和方法中。
查询参数动作交互过程如下图:
设置参数动作交互过程如下图:
下位机主动上报的数据流程如下:
SDK配置了将数据推送给集成程序的哪个类和静态方法中,那么数据将会被推送到指定的配置类中。