代码重构总结

Overview

1. 前言

时间拨回到8月底,刚刚完成了历史数据分区的自动创建和删除,服务端进入了无需人工维护的阶段,加上更早之前完成的网关控制、服务器压力测试,感觉暂时没有更多的功能点需要开发了,于是又开始动起合并代码的心思,做了一些准备工作后,从8月30号开始动手,总计添加38560行代码,删除27869行代码,净增10691行,9月20号完成第一个可运行的版本部署到内网测试环境,接下来是持续的修修补补。

2. loraserver

第一次接触loraserver是在2017年4月份左右,当时版本号还是0.17.2,作者Orne Brocaar是一位荷兰程序员。从提交历史看,第一次到现在已经过了3年时间,主项目loraserver的star数刚刚超过1000,其他组件包括lora-app-server、lora-gateway-bridge、lorawan等star数在200~300之间,全部使用MIT License。

从代码看,作者是一个十分有耐心而且码量巨大的人,对LoRaWAN协议有着深刻的理解,熟悉LoRaWAN节点、网关,对硬件特性有着一定的认识(也许做过硬件开发),熟悉GPS系统(其他类似的服务端仍旧不支持ClassB),对Redis、PostgreSQL、MQTT及Docker驾轻就熟,精通Go语言且代码风格规范,从协议到具体代码的抽象能力极强。

LoRaWAN本身是一个十分复杂的协议,但作者很完善地兼容了从协议、区域参数到服务端架构演变。LoRa Alliance在这几年中除了升级LoRaWAN协议外,还对服务端架构、区域参数等给出了标准文档,loraserver也可能是目前唯一的标准实现。

几年的时间里LoRaWAN从1.0版本逐渐演变出1.0.1、1.0.2、1.0.3、1.1等,服务端架构也从的NS、AS细分为fNS、sNS、hAS、AS、JS等,loraserver的项目版本号进入了3.0阶段,但基本架构不变,每个组件的功能如下

  • lora-gateway-bridge(bridge)
    • 对接pktfwd,解析UDP数据,转换为loraserver定义的内部数据结构
    • pktfwd配置同步
    • 使用MQTT连接到broker与loraserver交互数据
  • loraserver(ns)
    • 数据收集
    • 验证MIC
    • 处理上行MAC指令
    • 推送上行应用层数据给lora-app-server
    • 获取下行应用层数据
    • 生产下行MAC指令
    • 使用GRPC连接到lora-app-server
    • 使用MQTT连接到broker与lora-gateway-bridge交互数据
  • lora-app-server(as)
    • 内嵌网页端提供设备管理,实时调试功能
    • 处理join/rejoin请求
    • 处理应用层数据上行与推送
    • 提供外部API接口
    • 使用GRPC管理一个或多个loraserver
  • lorawan
    • LoRaWAN协议抽象
    • 频率计划抽象
    • phypayload、maccommands解析、组帧
    • 空中时间计算
    • gps时间转换
    • 应用层协议实现,如时钟同步、分包、多播等
    • 被以上三个组件依赖

除了代码实现外,作者还提供了loraserver-docker项目,利用docker-compose实现一键搭建协议栈服务。

从最初的小修改到现在自行开发服务端,Orne Brocaar的代码是我参考的最多的,包括项目初始化方式、数据库及redis封装、数据库表字段迁移、代码迁移、项目结构等等,在各个方面都是我的精神导师。

3. 功能实现

loraserver从一开始就是微服务化的,LoRa Alliance最初的定义中,只划分了NS及AS,这个架构在后台接口文档1.0版本中定义为私网网络模型,如下所示

lorawan-nrm-home

若需要支持公网漫游,则需要将NS进一步划分,如下所示

lorawan-nrm-roaming

目前loraserver尚未实现漫游,但预留了字段和接口。

开始当前负责的项目时,正好处于loraserver的2.0收尾阶段,作者的代码还在持续改进,短时间内难以稳定,于是沿用微服务的架构,首先实现了面向用户的设备管理及API接口,借助lora-app-server提供的设备管理与数据下发API,利用iot服务进行一层封装,暴露给用户的依旧是我们自研的服务器,于是形成以下的数据流。

来自节点的上行数据流

1节点 --> sx1301(网关射频硬件)--> pktfwd --> lora-gateway-bridge(服务端或内嵌于网关)  --> mqtt(服务端)--> loraserver --> lora-app-server --> iot服务 --> 用户服务器

来自用户的下行应用层数据HTTP请求数据流

1 HTTP请求 --> iot服务 --> lora-app-server --> loraserver --> mqtt(服务端)--> lora-gateway-bridge(服务端或内嵌于网关) --> pktfwd(网关软件) --> sx1301(网关射频硬件) --> 节点

这一阶段同时执行iot服务开发、协议栈(loraserver)优化、网关SDK以及一些应用层开发,幸好服务之间有明显的界限,许多任务交错在一起时还能分别开发上线,实现了大部分功能面向用户的基础功能:

  • 多用户及基于用户的认证、授权机制
  • 面向用户的数据下发及设备管理API接口
  • 可配置参数的HTTP数据推送
  • 设备批量注册、删除、数据导出
  • 实时数据及历史数据,包括网关层、节点协议层、节点应用层
  • 数据分区,自动创建和删除历史数据表
  • 支持B/C设备及基于B/C类设备的多播组
  • 支持多频率计划,通过部署多个loraserver实例实现
  • 文档输出,包括使用手册、数据推送、设备管理及协议原理
  • 网关内嵌SDK,提供远程控制和配置文件同步功能,使用MQTT连接服务器提高稳定性
  • ADR引擎优化,通过滤波算法平稳地调整节点速率
  • 支持离线部署和私网部署
  • license授权、试用期验证及设备数量限制
  • 暴露拓展协议接口,支持添加应用层拓展协议执行数据解析和数据下发
  • ...

从0到1的过程中,最困难的是如何把技术转换为产品提供给客户,我们的工作就像在推广一个数据链路层和网络层的产品,目前输出的还是面向开发者的功能,总结下来就是设备参数简化、设备管理、数据上报与下发等四个基础功能,而一些统计、调试、数据分析等细化功能,由于缺少专职产品经理,暂时无法实现。

4. 压力测试与优化

4.1 LoRaWAN协议及理论数值估算

LoraWAN是一个实时协议

  • A类工作模式下,节点上行一帧数据,然后等待1秒,开启接收窗口1,若未接收到数据则再等待1秒,开启接收窗口2,窗口1长度可配置,窗口2固定在窗口1之后1秒开启
  • B类工作模式下,除A类上行及随后开启的窗口1和窗口2外,根据PingSlot间隔定时开启B类接收窗口,同时会每隔128秒开启Beacon接收窗口以同步GPS时间
  • C类工作模式下,除A类上行及随后开启的窗口1和窗口2外,长时间开启C类接收窗口

如果节点需要在A类接收窗口1接收到应用层数据,则必须在1秒内完成上一节给出的节点数据上行流程和用户数据下行流程,通常来讲很难达成实时下发,因为无法确定应用层的业务耗时。若错过下发时间点,则用户下发的数据入队列,等待节点下次上行捎带,而如果节点支持B/C类工作模式,那么除开A类的两个接收窗口外,服务器可主动轮询队列执行下发,在下行流程中确定下发时间和频率。

从LoRaWAN的工作机制看,服务端必须是一个能够快速处理上行并根据条件触发下行的实时系统。

对于J个节点,假设每K秒发送一次心跳,且这些数据全部被网关接收到(无信道碰撞)

  • 10万个节点,每3600秒发送一次心跳,发送速率约28包/s
  • 1万个节点,每10秒发送一次心跳,发送速率1000包/s

可见节点总数及发包间隔会产生差距极大的发送速率,对服务端的压力也不在一个数量级,现场环境下由于信号碰撞、干扰等,很难达到理论计算的发包速率,SX1301芯片理论上可达到每秒160个数据包的接收速率,Semtech官方建议取20%占空比作为实际收包速率,而不同长度的数据包对接收端的占用时长不同,实际应用时,能够达到10%的比例已经是很不错的效果。

假设网关满载,每一个瞬间(取空中时间)可以同时接收8个数据包,且忽略处理时间,数据长度取51+13=64字节,根据SX1261计算程序计算可得理论数值如下

扩频因子 空中时间 单通道TPS 8通道TPS 网络层上行带宽 网络层下行带宽
7 118ms 8.47 67.76 9689.68B/s 5962.88B/s
12 2465ms 0.4 3.2 457.6B/s 281.6B/s

假设网关数量不限,无法满载可接收全部数据,不同节点数量及心跳间隔下的理论数值如下

节点数量 心跳间隔 TPS 网络层上行带宽 网络层下行带宽
1万 10秒 1000 139.64KB/s 85.93KB/s
1万 100秒 100 13.96KB/s 8.59KB/s
1万 1000秒 10 1.396KB/s 0.88KB/s
10万 10秒 10000 1396.4KB/s 859.3KB/s
10万 100秒 1000 139.6KB/s 85.9KB/s
10万 1000秒 100 13.96KB/s 8.8KB/s

4.2 由理论数据估算服务器负载

  • 对于低速应用场景,我们假定网关始终难以达到理想满载情况(不考虑碰撞、信道占用),因此可以以节点TPS估算服务器负载
  • 对于高速应用场景,我们假定网关维持在一个接近满载的情况(不考虑碰撞、信道占用),需要以网关TPS估算服务器负载

按10万节点估算

扩频因子 发包间隔 TPS 网络层上行带宽 网络层下行带宽 每日数据条数 每日数据量
7 10秒 1万 1396.KB/s 859.3KB/s 8.64亿 185GBx2.5
12 1000秒 100 13.96KB/s 8.59KB/s 864万 1.85GBx2.5

按1000台网关估算,取20%占空比

扩频因子 TPS 网络层上行带宽 网络层下行带宽 每日数据条数 每日数据量
7 1.32万 1887KB/s 1161.77KB/s 11.4048亿 245GBx2.5
12 640 89.375KB/s 55KB/s 5.53千万 12.18GBx2.5

上述数据分别代表服务器的

  • 最低要求:节点数据全部接收上传,服务器能够及时处理
  • 最高要求:节点数据被多个网关接收上传,网关满载,服务器能够及时处理

4.3 压测程序与压测结果

在指定硬件配置的情况下,系统总是存在一个平衡点,此时恰好能够及时处理所有输入数据。因此可通过逐级提升发包速率,并从服务器端统计接收总包数和总处理时间得到TPS,在临界状态前,服务端处理速率会随发送端增长,直到达到拐点。但是Golang原生支持并发,且系统内存在多级缓存,如MQTT、Redis等,因此还需要考虑是否出现数据乱序,因为部分goroutine被挂起时可能释放计算资源,导致总体处理时间下降。

基于以上考虑,编写了一个压测程序

  • 支持批量创建网关和ABP节点,用于压测模拟真实数据上行
  • 压测时获取M个节点平均分配到N个网关,根据输入的fCnt(帧计数器),从0开始组帧发包,模拟真实数据上行
  • 服务端所有数据包含时间戳,压测时全部入库
  • 压测结束时统计客户端发包速率和服务端TPS
  • 逐级提升发包速率,得到服务端最大TPS

服务端部署在一台四核心八线程的i7笔记本上,内存大小8GB,SSD硬盘,压测程序部署在另一台笔记本上,测试结果如下

序号 网关数量 网关关联节点数 节点发送间隔 数据入库 fCnt总数 发送总数 丢包数 服务器处理落后时间 fCnt乱序 耗时 TPS
1 50 10 2s 关闭 100 5万 0 1s 206.93s 241.62
2 50 10 2s 开启 100 5万 0 9s 180.87s 276.442
3 100 5 2s 关闭 100 5万 0 1s 209s 239.23
4 100 5 2s 开启 100 5万 1 8s 205.77s 242.977
5 50 8 2s 开启 100 4万 0 1s 204.54s 192.559

序号2的数据中,部分上行阻塞导致goroutine挂起,释放了CPU资源处理其他数据,由于并行处理,导致总时长减少,TPS增加,每个节点数据处理时间相差巨大,用户会接收到乱序fCnt。

在另一次测试中,取10万个节点平均分配到100个网关,每间隔400ms选1个网关+100个节点发送数据,则对于每个节点间隔6分43秒发送一次数据。连续发送2包,共20万包。测试结果为247包/s的处理速度,由于节点数增加,服务器负载有一定上升,其中redis的定期文件持久化由1万个节点时占用约15MB上升为144MB,redis定期持久化的次数也增加。

从测试结果看,笔记本的配置可以达到约240包/s的服务端数据处理速率,足以承载数量巨大的低速应用场景,但高速应用和海量节点更像是另一个维度的服务器才能处理的场景。

5. 重构

再回到重构开始的时间点

  • 已经熟悉loraserver、lora-app-server、loraa-gateway-bridge、lorawan等开源软件架构
  • 已经能对loraserver协议栈软件做适度的修改
  • 对LoRaWAN协议及实际应用有一定理解和经验
  • 基于loraserver的iot服务封装步入一个稳定阶段
  • 压测数据细化了可应用的场景

这个时候可以明确重构的目标

  • 合并loraserver、lora-app-server代码到iot服务,消除GRPC网络通信及额外的SQL查询带来的性能损耗,iot服务同时充当ns及as的角色
  • 重构loraserver中所有使用到band全局变量,使用传参的方式代替,以实现支持多频率计划的功能
  • 移植loraserver及lora-app-server的storage包,增加中间层转换,修改所有涉及的SQL及对象结构,让loraserve及lora-app-server代码使用iot服务的数据表
  • 移除上下行流程中所有可优化为Redis查询的的SQL查询

5.1 loraserver架构

loraserver的代码一直保持着良好的架构,如下所示

loraserver_arch

最外层的模块功能如下

  • api:GRPC接口定义,包含
    • as(ApplicationServer):定义了提供应用服务的接口,包括应用层上行数据处理、设备状态、设备地理位置信息处理等
    • ns(NetworkServer):定义了提供网络服务的接口,包括设备、网关与多播组增删改查等等,提供协议栈主要功能的服务
    • nc(NetworkController):定义了处理自定义MACComand与网关信息的网络控制器服务
    • gw(Gateway):网关数据帧内部格式定义
    • geo(GeolocationServer):地址位置解析服务
  • cmd:loraserver启动命令,提供了配置文件参数定义与解析,内部模块启动等功能
  • docs:文档
  • internal:内部代码
  • migrations:SQL文件,用于创建更新数据库表格,在编译时转化为二进制文件并入可执行程序

我们需要合并的是internal包与模块启动代码,修改频率计划变量改为传参调用,移植数据库表格适配iot服务数据,首先明确internal包的各个模块功能如下

  • adr:ADR引擎代码实现,生成用于调速的LinkADR指令
  • api:ns接口定义实现
  • backend:ns的后端服务定义及实现,包括MQTT订阅发布、节点上行数据预处理、网关状态数据预处理、节点下行数据发布、网关ACK数据处理等,用于连接各个内部模块
  • channels:生成用于同步信道的LinkADR指令
  • common:内部对postgresql及redis的封装
  • config:配置文件定义,包含所有模块引用的全局变量
  • downlink:节点下行流程,B/C类主动下行,多播主动下行实现
  • framelog:提供基于redis的数据帧发布订阅服务,可以在上下行流程中打点使用
  • gateway:网关状态包、网关Meta信息及高精度时间戳解密
  • gps:GPS及UTC实现相互转换
  • helpers:一些工具函数,设置上下行数据速率、网关ID转换、获取数据速率等
  • maccommand:mac指令生成及mac指令响应的处理
  • migrations:缓存迁移代码及自动生成的数据库迁移代码,通过go-binddata生成
  • models:内部模型定义,主要包含用于数据收集阶段结束时进行排序的RXInfo定义
  • storage:存储层定义,包含SQL及存储层的model定义
  • uplink:节点上行流程、join、rejoin及数据收集、去重实现

节点数据流如下

1MQTT --> backend(payload、频点、频率计划去重) --> collect(按payload去重、排序)-->  data/join/rejoin/proprietary(根据数据类型选择对应的上行模块)--> 通过GRPC调用as或者HTTP调用js -->  进入数据类型对应的下行流程 -->  MQTT

5.2 lora-app-server架构

lora-app-server的架构与loraserver类似

lora-app-server_arch

最外层的模块功能如下

  • api:GRPC接口定义,通过swagger生成json格式的接口定义,并使用grpc-gateway转换为HTTP服务供前端调用
  • cmd:lora-app-server启动命令,提供了配置文件参数定义与解析,内部模块启动等功能
  • docs:文档
  • internal:内部代码
  • migrations:SQL文件,用于创建更新数据库表格,在编译时转化为二进制文件并入可执行程序
  • ui:使用React实现的后台网页代码
  • static:后台网页构建结果,会在编译时转化为二进制文件并入可执行程序

我们需要合并的是internal包中as(ApplicationServer)的服务实现,主要包括

  • join/rejoin请求处理,生成JoinAccept请求
  • 节点上行数据处理,包括密钥同步、应用层Payload解密
  • 节点下行ACK响应处理
  • 节点错误处理,主要是fCnt乱序与Payload超过指定长度
  • Proprietary数据包处理,这是LoRaWAN预留给用户实现自定义协议的类型
  • 节点状态包处理,同步电量、供电状态及SNR余量
  • 节点地理信息同步

因此可删除internal/api包及其引用的包以外的所有代码,internal包各个模块功能如下

  • api:
    • as接口定义、join服务实现
    • 应用、节点、网关、多播组、ns、组织、用户等增删改查实现
  • codec:提供基于JavaScript的应用层自定义协议编解码实现
  • common:内部对postgresql及redis的封装
  • config:配置文件定义,包含所有模块引用的全局变量
  • downlink:数据下发接口服务实现
  • eventlog:提供基于redis的数据帧发布订阅服务,为应用层事件提供打点
  • gwping:基于proprietary数据帧,实现网关之间相互ping的功能,当一个网关接收到另一个网关的ping上报后,可以确定两个网关在相互覆盖范围内
  • handler:应用关联的handler,提供gcppubsub、http、influxdb等存储、推送上行数据
  • join:处理join、rejoin请求,生成JoinAccept所需参数
  • migrations:自动生成的数据库迁移代码,通过go-binddata生成
  • multicast:提供多播数据下行实现
  • nsclient:ns对象缓存池
  • static:自动生成的前端文件二级制数据存储,通过go-binddata生成
  • storage:存储层定义,包含SQL及存储层的model定义

5.3 代码合并

iot服务的架构也源自loraserver和lora-app-server,如下所示

iotserver_arch

具体的模块功能不在赘述,backend中包含了原本用于对接lora-app-server的代码,且通过打点数据记录了所有协议层内容,因此初步合并思路如下

  • loraserver/internal包全部合并到backend中,统一修改internal中模块对其他模块的引用路径
  • 沿用loraserver的api模块中的GRPC对象定义,为backend分别实现ns及as的方法集,封装调用internal中原有的模块
  • 确保初步合并后无编译错误,对原有接口无影响

然后是工作量最大的两个重构

loraserver存储层移植

原先通过多层微服务的调用逻辑中,一次数据下发中至少会触发三个SQL查询,分别是iot服务层权限验证、lora-app-server层权限验证、loraserver层SQL操作,效率相对较低,重构后移除中间的lora-app-server及两层的网络调用,让loraserver直接访问iot服务的数据库,实际重构时,修改了大量代码,涉及节点、网关、多播组等,还包括两个主动下行队列。

全局频率计划变量引用改为传参

原先的loraserver架构中,频率计划是在程序初始化时配置,整个程序的代码在需要引用band的时候,直接访问config包中的全局变量,从开始进行数据收集到最后发送下发,每一个涉及LoRaWAN参数的函数调用都涉及对频率计划的引用,实际重构时,数据从MQTT进入到程序处理流程时,便绑定一个频率计划标签,从而获取到预定义的频率计划对象。

最后是重构原有代码,将对lora-app-server的HTTP调用,转为对backend的方法调用,至此完成第一版本的代码合并,剩下的是持续修修补补。

6. 总结

  • package之间的调用尽可能通过接口定义,如果初期难以确定全部接口定义,可以先实现为对象的方法,后面再增加接口定义
  • 接口定义可以在本地实现,也可以通过网络调用实现,只要确保传递的参数不变,两者之间的转换代价是可以接受的
  • 规范的代码和一致的风格会形成一种约定,这种约定能让我们在充满不确定性的问题中找到思路
  • 重构的初衷是为了提升效率,减少维护成本,但不能改变协议本身,LoRaWAN不适合高速应用
  • 微服务需要更多的人手维护,单体应用适合独立开发,模块化良好的单体应用足以支撑项目演变到微服务的时机
  • 底层代码(协议栈相关)重构对专业知识的要求远大于代码本身
  • 还是单体应用好