目录
注意:本文写于2018年11月初,分析对象是golang版本的EdgeX Foundry平台,此时edgex-go:delhi版本(https://github.com/edgexfoundry/edgex-go)刚发布不久。EdgeX Foundry最初使用java开发,但从California版本(delhi的上一个版本)开始使用golang重构整个系统,目的是减少程序体积, 启动时间, 内存和CPU占用率等,从而可以让其在类似Raspberry Pi这样的嵌入式板卡上面流畅运行,golang重构后的对比如图 1。整个项目目前仍处于开发过程中并未全部完成,如support-rules暂不支持,其他微服务也处于不断完善的过程中。
文中内容是通过阅读源码结合官方文档总结而来,源码分析的结果多以各个微服务的内部结构图呈现,因此希望这些图示能为理解源代码带来帮助。
EdgeX Foundry的定位是通用工业IOT边缘计算通用框架,其架构如图 2。分南北两边,4个逻辑层和贯穿始终的安全以及设备&系统管理服务:
最底层是设备服务层,即与物理设备直接通信的具体微服务的集合,每个设备微服务可以管理支持对应接口的多个物理设备;设备服务层之上是核心服务层,包括core-data, core-command, core-metadata, 和registry&config,其中registry&config使用Google开源的golang版本的服务发现、配置管理中心服务consul,因此项目中没有关于registry&config微服务的源代码;核心服务层之上是支持服务层,提供日志、规则引擎、提醒等通用服务;最上层是输出服务层,主要包括两个微服务client registration和distribution,前者负责输出数据模型的注册服务,后者从前者拉取注册信息并依此将数据转发出系统之外,转发的目的地可以是远程的云端服务,也可以是本地的其他服务。边缘计算的原意是将计算放在边缘处理,可是从EDGEX的架构图中并未直接发现计算相关的微服务,稍微与计算相关的微服务distribution里面有数据的过滤、格式化、压缩和加密等等,但这并不是真正的数据处理,个人认为数据处理涉及到的数据类型、规模以及处理的方法不容易定义一个通用的框架或微服务,因此EDGEX并未提供,additional services便是EDGEX留给用户自定义的微服务用于数据的处理。
南边之下是众多的物理设备,具有相同接口的一个或多个设备由同一个device service接入系统;北边之上是数据的最终归处,可以是远程的云端,也可以是本地的数据处理服务。
设备&系统管理服务也是一个微服务,只不过该微服务统一负责系统中其他微服务的生命周期管理,目前并未完善。
安全问题目前为止官方文档和项目源码并未过多涉及,估计指的是各个接口的加密以及设备数据和指令的保护,如增加权限管理等等,纯属个人猜测,期待项目更加完善后有比较清晰的安全管理模型及介绍。
此外,EDGEX的松耦合微服务架构意味着其可以部署到一个或多个节点上,具有横向扩容的能力。
Core-Registry&Config使用google开源的 go语言版本的服务发现、配置管理中心服务consul实现。内置了服务注册与发现框架、分布一致性协议实现、健康检查、Key/Value存储、多数据中心方案,不再需要依赖其他工具(比如ZooKeeper等)。开源产品,详细信息请参考官方文档。这里仅需知道:consul的服务注册与发现是微服务架构系统得以动态伸缩的关键,在系统部署的过程中他应该是第一个被启动的服务。
Support Logging的结构非常简单如图 3,日志的持久化目前支持文件和数据库类型,推荐数据库类型。此外Logging本身自己有一个私有的LoggingClient提供自身运行时的日志记录功能,这个模块是其他微服务所没有的。Support Logging的对外接口使用HTTP,其他微服务通过该接增加,查询和删除日志,内部共有4类逻辑功能:
PING返回字符”pong”, 为检测服务是否正常运行提供接口。
CONFIG返回全区配置变量Configuration,该变量可由Consul服务远程更新,consul client会将更新的Configuration变量发送至chConfig通道,通道另一端接收Configuration并更新全局变量。
Metrics使用runtime.MemStats返回微服务运行时的内存使用状态。
Logs三个逻辑分别对应了日志的增加,获取与删除,其中日志的获取与删除提供了更富的过滤功能,如按照标签,关键字,日志等级,所属服务等等实现日志的匹配。
注意:PING,CONFIG和METRICS在所有的微服务中的实现原理和功能均相同,因此后面不再重复介绍。
图 4是微服务Core Data的内部结构,其主要功能是提供数据持久化,通过Restful API的方式向外提供服务。来自外部的http请求(主要是设备微服务和系统操作者)经过Router层路由到相应的业务逻辑,core data共有5大业务逻辑分别是:EVENT、READINGS、VALUE DESCRIPTOR、PING和Config。
VALUE DESCRIPTOR 提供数据描述的创建、更新与获取服务,任何设备的数据在上传至core data之前必须先通过该接口创建数据的描述,否则数据上传会失败。VALUE DESCRIPTOR与设备或传感器数据存储在同一个数据库中,该数据库当前默认是MongoDB,数据库可以部署在同一台机器上,也可部署在不同物理机上,具体取决于应用需求。VALUE DESCRIPTOR的注册应该是操作者根据设备的具体情况在设备以及设备微服务部署之前就注册在系统中。
READING是设备或传感器采集到的数据描述,找包括数据创建时间,名称和数值等等。READING接口直接将这些上传的数据存入数据库,与此同时也提供基本的数据查询接口。虽然core-data提供了READING上传的接口,但是不推荐直接使用该接口上传READING,通常情况下READING被嵌入在EVENTS中上传到系统中。
EVENTS应该是最为繁忙和最为核心的接口,主要负责EVENTS的上传、更新和查询。每个EVENT可以包含多个READING。每次EVENTS的上传的同事还会将EVENTS推送到:
Core-Metadata主要是EDGEX系统中设备微服务和设备以及设备数据相关的元数据管理,共有9大接口,分别是:Schedule, Schedule Event, Device, Device Report, Provison Watcher, Device Service, Device Profile, Addressable, Command。这些接口分别提供了对应数据结构的增删改查功能,并提供了丰富的过滤功能,如按标签、名称以及ID实现更新、查询和删除。任何设备或设备微服务在部署之前都必须将其相关信息注册在core-metadata之中,还有少许信息需注册在core-data之中如ValueDescriptor。
Core-Metadata启动之初的Configuration有一个临时的updataCh通道从Consul获取,以后的更新从chConfig通道获取。
DeviceService数据结构是每一个设备微服务实例在metadata中的一对一映射,每一个设备微服务启动之初便会向metadata查询相应的DeviceService是否已经存在,若存在则认为初始化完成(这种情况一定是该设备微服务之前已经成功启动,因为某种原因退出,然后再启动);若不存在则会利用DeviceService接口向metadata注册自己的相关信息。DeviceService数据结构如代码 1,其中LastConnected和LastReported由core data中的chEvents通道中的数据更新以保持该数据结构的最新状态。
type DeviceService struct {
Service
AdminState AdminState `bson:"adminState" json:"adminState"`
}
type Service struct {
DescribedObject `bson:",inline"`
Id bson.ObjectId `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name"`
LastConnected int64 `bson:"lastConnected" json:"lastConnected"`
LastReported int64 `bson:"lastReported" json:"lastReported"`
OperatingState OperatingState `bson:"operatingState" json:"operatingState"`
Labels []string `bson:"labels" json:"labels"`
Addressable Addressable `bson:"addressable" json:"addressable"`
}
Addressable描述的是服务的接入地址如代码 2,需要注意的是Addressable的Name和Service的Name均是指的设备微服务的名字,因此他们必须相同。与此同时同一个设备微服务的实例只能是一个,这也很好理解:设备微服务向下挂了具有相同接口的一个或多个设备或传感器,以MODBUS为例,多个MODBUS设备被一个Device Service管理就足够了,没有必要实例化多个相同接口的设备微服务。
type Addressable struct {
BaseObject `bson:",inline"`
Id bson.ObjectId `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name"`
Protocol string `bson:"protocol" json:"protocol"`
HTTPMethod string `bson:"method" json:"method"`
Address string `bson:"address" json:"address"`
Port int `bson:"port" json:"port,Number"`
Path string `bson:"path" json:"path"`
Publisher string `bson:"publisher" json:"publisher"`
User string `bson:"user" json:"user"`
Password string `bson:"password" json:"password"`
Topic string `bson:"topic" json:"topic"`
}
DeviceService接口提供了Service数据结构的上传、更新、查询和删除功能,其中更新和查询功能提供了丰富的过滤功能,如按标签、名称以及ID实现更新、查询和删除。
Schedule和ScheduleEvent必须配合使用:schedule仅提供时间的触发规则,如启止时间范围,触发频率,运行次数以及更加高级的触发功能Cron。ScheduleEvent通过其ScheduleEvent.Schedule字段与Schedule连接,其他字段如Addressable提供事件的访问地址,Parameter提供事件触发时传递的参数。
type Schedule struct {
BaseObject `bson:",inline"`
Id bson.ObjectId `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name"`
Start string `bson:"start" json:"start"`
End string `bson:"end" json:"end"`
Frequency string `bson:"frequency" json:"frequency"`
Cron string `bson:"cron" json:"cron"`
RunOnce bool `bson:"runOnce" json:"runOnce"`
}
type ScheduleEvent struct {
BaseObject `bson:",inline"`
Id bson.ObjectId `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name"`
Schedule string `bson:"schedule" json:"schedule"`
Addressable Addressable `bson:"addressable" json:"addressable"`
Parameters string `bson:"parameters" json:"parameters"`
Service string `bson:"service" json:"service"`
}
DeviceProfile与设备配置文件成对应关系,任何设备再部署之前都必须上传其DeviceProfile信息。DeviceProfile中基本的信息包括名称,制造商,型号,标签等等等,最为复杂的可能要数设备资源,资源和指令这3个字段,其具体数据结构请参考代码 5。DeviceProfile中包含了大部分的系统正常运行需要维护的数据结构类型,此处不做过多介绍近贴出源码如代码 5,各字段的物理含义请参考官方文档。
type DeviceProfile struct {
DescribedObject `bson:",inline" yaml:",inline"`
Id bson.ObjectId `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name" yaml:"name"`
Manufacturer string `bson:"manufacturer" json:"manufacturer" yaml:"manufacturer"`
Model string `bson:"model" json:"model" yaml:"model"`
Labels []string `bson:"labels" json:"labels" yaml:"labels,flow"`
Objects interface{} `bson:"objects" json:"objects" yaml:"objects"`
DeviceResources []DeviceObject `bson:"deviceResources" json:"deviceResources" yaml:"deviceResources"`
Resources []ProfileResource `bson:"resources" json:"resources" yaml:"resources"`
Commands []Command `bson:"commands" json:"commands" yaml:"commands"`
}
type DeviceObject struct {
// DescribedObject `bson:",inline" yaml:",inline"`
// Id bson.ObjectId `bson:"_id,omitempty" json:"id"`
Description string `bson:"description" json:"description"`
Name string `bson:"name" json:"name"`
Tag string `bson:"tag" json:"tag"`
// Properties ProfileProperty `bson:"profileProperty" json:"profileProperty"`
Properties ProfileProperty `bson:"properties" json:"properties" yaml:"properties"`
Attributes map[string]interface{} `bson:"attributes" json:"attributes" yaml:"attributes"`
// Other string `bson:"other" json:"other"`
// Other map[string]string `bson:"other" json:"other"`
}
type ProfileResource struct {
Name string `bson:"name" json:"name"`
Get []ResourceOperation `bson:"get" json:"get"`
Set []ResourceOperation `bson:"set" json:"set"`
}
type Command struct {
BaseObject `bson:",inline" yaml:",inline"`
Id bson.ObjectId `bson:"_id,omitempty" json:"id"`
Name string `bson:"name" json:"name" yaml:"name"`
Get *Get `bson:"get" json:"get" yaml:"get"`
Put *Put `bson:"put" json:"put" yaml:"put"`
}
type ResourceOperation struct {
Index string `bson:"index" json:"index"`
Operation string `bson:"operation" json:"operation"`
Object string `bson:"object" json:"object"`
Property string `bson:"property" json:"property"`
Parameter string `bson:"parameter" json:"parameter"`
Resource string `bson:"resource" json:"resource"`
Secondary []string `bson:"secondary" json:"secondary"`
Mappings map[string]string `bson:"mappings" json:"mappings"`
}
type Get struct {
Action `bson:",inline" yaml:",inline"`
}
type Put struct {
Action `bson:",inline" yaml:",inline"`
ParameterNames []string `bson:"parameterNames" json:"parameterNames" yaml:"parameterNames"`
}
type Action struct {
Path string `bson:"path" json:"path"`
Responses []Response `bson:"responses" json:"responses"`
URL string `json:"url,omitempty"`service
}
type Response struct {
Code string `bson:"code" json:"code" yaml:"code"`
Description string `bson:"description" json:"description" yaml:"description"`
ExpectedValues []string `bson:"expectedValues" json:"expectedValues" yaml:"expectedValues"`
}
图 6是Core-Command的结构图,共有6类处理逻辑:
GetAllCommands如其名直接从core-metadata拉取所有设备的所有可用命令;By Name(restGetCommandsByDeviceName)根据设备名称获取单个设备的所有可用命令。通过这两个接口便可以知道系统内所有设备的所有可用命令及其使用方法和相关描述,方便用户后续执行命令。
By Device(restGetDeviceCommandByCommandID)提供了按照设备ID和命令ID执行特定设备对应命令的功能。他们的工作方式是:首先根据设备ID,命令ID从core-metadata中获取特定的设备和指令的数据结构并将其二者结合形成完整的命令访问地址。然后访问该命令地址,如果是PUT命令便将HTTP请求中的BODY当做参数传递到命令请求的参数列表中,由于设备仅能直接被Device Service操作,因此可以知道命令执行操作请求的目的地址必然是Device Service,Device Service内部应该实现了对应设备和对应命令的具体逻辑,需要注意的是Device Service与物理设备的命令执行接口不限于HTTP协议。简而言之,core-command和Device Service之间通过HTTP通信,但Device Service与物理设备之间的通信方式不仅仅只有HTTP,还有其他如MODBUS,BACNET,SPI,IIC等,Device Service需要实现HTTP到对应协议之间的转化。
support-schedule启动之初便分别从core-metadata和配置文件中加载schedule和schedule event信息并填充内部维护的5个Map型全局变量,与此同时形成ScheduleContext并推入scheduleQueue通道。support-schedule内置了一个定时器ticker,该定时器的默认触发周期是500ms,也意味着schedule的最小触发周期是500ms,该数值可以修改。定时器每次触发均会执行函数triggerSchedule。
triggerSchedule首先会判断scheduleQueue中是否有数据,有则继续,否则退出。然后按顺序依次取出scheduleQueue中的ScheduleContext数据并判断其是否被标记为删除,如果没有则根据其执行规则将其直接送入scheduleQueue或者execute执行,如果有则忽略这个ScheduleContext不做任何处理。
execute会依次处理ScheduleContext中scheduleEvent,所谓处理即根据scheduleEvent的Addressable和parameters访问其对应的服务(疑问:访问服务后的返回参数未作处理?)。每个ScheduleContext处理完后会根据其schedule的启止时间和运行次数限制更新参数并判断是否再次写入scheduleQueue。triggerSchedule函数通过sync.WaitGroup会等待scheduleQueue中所有数据处理完毕后在退出。(疑问:triggerSchedule以go协程的方式周期性的创建,存在下个周期到来之前当前周期的triggerSchedule仍未运行完毕,即triggerSchedule的运行时长超过其触发周期。而这种可能性在网络状态不佳的时候是很大的,即便是本地网络。)
Flush是重新加载schedule和schedule event的信息;info根据schedule name查看scheduleNameToContextMap中的信息;callback提供了从support-schedule外部动态增加、更新和删除schedule,schedule event的功能。
Support-Notification从官方的架构图来看结构非常清晰,支持的接口也非常丰富,不过很可惜目前为止图中的绝大部分内容并未实现。从架构图中可以看出:警告或提醒消息的订阅接口通过restful Interface实现,用户事先将自己需要订阅的消息类容注册进系统,本质上就是创建Subscription数据结构,Subscription中包含了订阅种类(SubscribedCategories)和标签(SubscribedLabels)的字段。
消息的来源支持3种类型的接口:restful Interface,AMQP Listener和MQTT Listener,目前仅有restful Interface被实现。当restful Interface接收到消息,即接收到数据结构Notification时,目前的处理方法是直接将该消息在restful Interface对应的逻辑处理函数中直接处理,这是一种代码紧耦合的处理方式,并未像图 8中所示将Notification Handler分离出来,这可能是目前并未实现AMQP Listener和MQTT Listener之前的权宜之计。处理的方式也很简单:Notification数据结构中包含了订阅种类和标签字段,拿这两个字段去过滤Subscription数据库取出对应的Subscription数据结构,然后根据Notification的Severity字段选择将紧急消息直接全部发送出去。普通消息先暂时写入数据库,这里需要注意的是写入数据库后按理说应该存在另一个线程或定时器或其他实现方式的代码,该代码周期性的取出数据库中未被标记处理的Notification并将其发送出去,可目前也并未找到这部分的实现源码。总之官方架构图的结构非常清晰,但目前的实现并未完全按照架构图走,尤其是Notification Handler和Distribution Coordinator的代码与restful Interface逻辑紧耦合,message schedule更是未实现。
Channel即消息发送的通道,从图中看出支持5种,实际上目前仅实现了Mail Sender和REST Callback两种。
Export-Client的功能非常简单:维护数据结构Registration,即提供Registration的增删改查功能,其结构如图 9所示。其中对Registration的增加,更新和删除操作在更新数据库后还需要提交相应的Notification给export-distro,以实现export-distro中Registration处理协程的实时动态伸缩。
Export-distro的结构如图 10所示,服务启动之初便会从export-client加载所有的注册服务即Registration数据结构,并为每一个Registration单独开启一个处理协程如图 10中的registrationLoop。
每一个registrationLoop均有一个reg.chEvent通道,ZeroMQ Receiver会将接收到的EVNET(详细参考core-data章节)送入eventCh,eventCh另一端是所有的registrationLoop,即ZeroMQ Receiver将接收到的EVENET转送给了所有的registrationLoop。
registrationLoop内部会将接收到的EVENT送入处理程序reg.processEvent,处理顺序如图 10依次是数据过滤,格式化,压缩,加密和发送,其中虚线框的处理步骤是可选的,发送的对象export-receiver是本地的服务或远程的云服务等等(如FAAS函数计算等等,据说官方正在考虑接入FAAS)。
每一个registrationLoop内部还有一个更新通道和删除标志用于registrationLoop的动态生命周期管理(新建,更新,删除),图中registrationChanges的数据来自黄色方块的处理逻辑registrations,而处理逻辑registrations的请求者即是Export-Client中的Notification-Client。
设备微服务是唯一一个用户需要根据物理设备接口协议自己开发的微服务,官方已提供Device Service SDK,用户只需用实现特定的几个接口和设备数据结构即可。不过可惜的是golang版本目前未实现任何接口类型的Device Service。因此待以后官方提供特定接口Device Service实现的案例之后再做分析。
EDGEX FOUNDRY得益于其微服务架构,个人认为既可以部署在Raspberry PI这样的嵌入式板卡上,也可以部署在高性能的服务器上,其部署规模具有高度的伸缩性和灵活性。作为边缘计算的平台其功能也是非常丰富的,并且允许用户扩展系统功能(即自行开发微服务)。
EDGEX FOUNDRY未提供Export-distro之后的数据处理案例,需要用户自行开发,个人认为FAAS是一个很不错的选择之一,此外EDGEX FOUNDRY也目前没有提供大规模部署的解决方案,商业的边缘计算服务如阿里Link Edge提供了从云端直接管理边缘端的功能,为大规模的的边缘节点提供云端的统一管理入口。不过EDGEX FOUNDRY目前处于开发过程中,相信以后会持续完善。
EDGEX FOUNDRY即便是本地的使用管理目前而言也非常不方便,比如假设我要安装一个设备,我需要注册设备的profile,注册对应设备微服务的配置,注册相关的Addressable,注册Schedule,注册Schedule Event等等,而这些注册服务通常涉及不同的微服务,目前用户只能使用postman类似的工具相当于以命令行的方式使用,这对用户尤其是初学这是相当困难的,我至少花了两周时间才基本弄清楚系统中的数据结构及其关联关系以及不同数据结构在注册时的优先顺序,而了解的过程只能是通过源代码。因此提供一个统一的管理控制平台非常有必要,事实上官方也提供了一个这样的工具edgex-ui-go(https://github.com/edgexfoundry/edgex-ui-go),不过似乎新版本并未完成而且很久没更新了。
总而言之,EDGEX FOUNDRY虽然目前使用不易,但随着开发进程的持续,系统功能的完善与成熟以及IOT的蓬勃发展,EDGEX FOUNDRY作为为数不多的优秀开源边缘计算框架之一必然会被越来越多的人所熟知与使用。