我之前有篇文章《智能驾驶域控制器的软件架构及实现》,其中对中间件有简短的论述。本文是将中间件作为一个专题,专门展开进行详细的分析和讨论。
中间件相关技术在计算机分布式系统中发展了很多年,尤其在互联网服务、大型商业系统中得到广泛使用。随着智能网联汽车的发展,现代汽车也逐步增加了以太网支持,这让之前的很多分布式系统技术也可以运用到汽车软件中,比如 SOA 软件架构。所以,基 于 SOA 的中间件也得到了越来越多的重视。
但是大家在讨论这些问题时,对很多概念表述其实很模糊。什么是中间件,不同语境下其含义差别很大。对于什么是 SOA,自动驾驶系统需要 SOA 吗,很多人也很困惑。本文结合中间件的发展历史、软件架构方法论,自动驾驶的特殊要求,做了一个综合性分析,给出这些问题的一家之言。
第一章对典型的中间件产品做了一些介绍和综述。并阐明了中间件产品的核心概念,简述中间件技术在互联网和车载系统两个领域的应用。
第二章对中间件涉及到的关键技术逐一进行说明,作为后续分析的知识基础。
第三章对软件架构的分析方法和软件架构风格做了通用性的论述,并以此方法论逐层递进推导 SOA 软件架构。
第四章在前文的基础上,进一步分析自动驾驶对 SOA 中间件的要求。并以 Adaptive AutoSAR 和 GENIVI 技术体系为基础,举例说明如何对其进行改进与扩充,以实现满足自动驾驶要求的中间件系统。
本文的读者定位为从事车载软件开发、自动驾驶系统开发的系统工程师,产品经理、软件架构师、算法工程师、软件开发工程师及测试人员。因为智能驾驶需要很多不同专业的人协同工作,并不是所有人都是软件或汽车软件背景。有些论述对计算机软件专业的朋友可能是基本常识,但对其它专业的朋友而言并不熟悉,为了能让各种不同背景的人都能一定程度上理解文章内容,本文尽量采用非常通俗的语言来描述,并配合各种图来进行阐述。本文避免使用有歧义的术语,所有术语在第一次出现时都给出其在本文的准确定义。
中间件基础概念
1.1 典型的中间件产品的介绍ppPednc
1.1.1 CORBA 及其衍生物ppPednc
中间件这个词已经被使用了很多年,其含义范畴也在不停的演变。最早可以追溯到1991 年 CORBA 1.0 标准的诞生. CORBA 官方自述:"CORBA 是由对象管理组(OMG) 开发的标准,用于提供分布式对象之间的互操作性。CORBA 是世界领先的中间件解决方案,支持信息交换,独立于硬件平台、编程语言和操作系统。"ppPednc
不过 CORBA 标准过于庞大复杂,很多公司都参与制定标准,为各自的利益加入很多复杂而不实用的特性,同时很多公司又独立各自另搞一套。所以实际上 CORBA 并没有大范围流行,尤其在互联网应用中实际很少有人采用。其开源版本 omniCORBA [3] 从1997 年发布第一版到 2020 年仍在持续更新,其网站主页一如 20 年前一般简洁朴素。ppPednc
几个参与 CORBA 标准开发人员后来也嫌 CORBA 过于复杂,然后成立了一个公司开发了一个轻量级的支持“分布式对象”的系统[4],Zeroc ICE . 它汲取了 CORBA 最核心的特性,做了更简洁高效的实现,并支持 10 多种语言的绑定。同时提供了一个中心化的发布订阅服务(Ice Storm), 支持网格计算等等。经过 20 多年的发展,已经非常成熟可靠。在军工、通信、游戏等领域有很多使用者,但知名度始终不是非常高,或许跟创始人的经营理念有关系,并没有做较大的推广。ppPednc
CORBA ,ZeroC Ice 的应用场景都是开发跨网络的分布式应用。其作为中间件的核心作用主要有:ppPednc
• 使用接口定义语言(IDL)进行规范化的通讯协议描述,让应用开发者关注通讯内容的业务语意,不需要去定义协议报文的细节。ppPednc
• 以本地函数调用的方式对远程对象进行操作,中间件对应用层屏蔽具体通讯的细节。ppPednc
它们都提供的接口定义语言(IDL)来描述通讯接口。提供工具根据 IDL 生成目标语言的代码骨架,与目标语言集成。这也是大多数通讯中间件的典型做法。ppPednc
ppPednc
1.1.2 互联网时代的企业级中间件ppPednc
互联网时代,很多人对中间件的认知是从 J2EE 体系的企业级中间件开始的。J2EE的核心理念是将企业应用的商务逻辑实现在一个个的 Enterprise Java Bean(EJB) 中。EJB 可以类比与 CORBA 中的远程对象,运行在由 J2EE 中间件平台提供容器中。容器由中间件供应商开发,提供了 EJB 运行所需要的环境。应用开发人员只需要开发与业务逻辑相关的 EJB 组件。ppPednc
ppPednc
图 1. 1 J2EE 中间件ppPednc
J2EE 定义了一系列标准,涉及到接口定义、名字服务、远程调用、数据库访问、事务处理等等。重量级的商业实现有 WebLogic 和 WebSphere, 轻量级开源的有 JBoss 和 Tomcat,这些都被称为 J2EE 中间件或 J2EE 应用服务器。然而在实际应用中,J2EE暴露出很多问题。一方面规范多而复杂,解决简单问题也需要先了解太多的技术内容,学习曲线陡峭;另一方面其运作体制是几个大厂商定期开会,定义、发布标准,各厂商实现应用服务器产品再发布更新,用户获取新版版本。这个周期对于快速迭代的互联网来说,实在太慢了。ppPednc
J2EE 和 CORBA 有一个共同的问题,就是过于注重标准的完备性,而不关注开发的实用性。实际开发实践中,被广泛采用的是 Spring Framework。它不算完整的 J2EE 实现,但是也使用了很多 J2EE 规范,却更注重实用性,快速迭代解决现实问题。ppPednc
1.1.3 轻量级的 RPC 框架ppPednc
相对于重量级作为应用服务器存在的中间件,轻量级的 RPC(远程过程调用)框架被使用得更为广泛。这些 RPC 框架最基本的用途就是简化网络通讯程序的编写。ppPednc
一般编写一个采用 TCP 或 UDP 的通讯程序,至少要自己定义通讯协议的报文格式;根据协议用代码进行报文内容的拼装与解析;如果直接使用原生的 Socket API ,没有经验的话很容易掉入各种陷阱;还要考虑异步 IO 机制以得到更好的性能。轻量级的 RPC 框架的目的就是帮助开发人员把这些与业务逻辑无关的通讯底层工作都做了,通讯协议使用IDL 定义,通讯相关代码都自动使用工具自动生成出来。开发人员集中精力处理与业务逻辑相关的数据处理。ppPednc
Apache Thrift 是比较常用的一个 RPC 框架。它定位为一个“可扩展的跨语言服务实现”。其设计中也重点体现了功能扩展方便,增加语言支持也很方便。官方版本已经提供了 C++、Java、Python、PHP、Ruby、Erlang、Perl、Haskell、C#、 Cocoa、JavaScript、Node.js、Smalltalk、OCaml 和 Delphi 等语言的支持。其架构上也可以支持方便的替换不同的通讯通道(TCP/UDP 或共享内存)和数据序列化方式。而对线程调图 1. 1 J2EE 中间件度、异步操作等都只做了最精简的实现,这让它移植到一个新的语言也比较简单。其重点放在了跨语言的互操作性上。ppPednc
相对于 Thrift,gRPC 更强调性能,对语言的支持略少一些,包括 Java、C#、Go、JavaScript、、Swift 和 NodeJS 等。其序列化协议就是 Protobuf,传输协议为HTTP/2,两者都是固定的,不能替换,这两者在性能上的优势也是 gRPC 高性能的原因之一。尤其是其序列化协议 Probobuf ,得到了广泛的应用。ppPednc
与前文提到的 CORBA、ZeroC ICE、EJB 这些面向对象的中间件不同的是,Thrift 和gRPC 都是面向服务的。在分布式中间件设计中,“面向服务”实际上比“面向对象”更简单一些,但是在很多场合,反而更实用。关于这一点,在下文的 3.5.1 节有更详细的讨论。ppPednc
1.1.4 消息中间件ppPednc
基于消息的通讯中间件也在很多领域被广泛运用。RPC 机制的视角是:从“客户-服务”之间的需要一个通讯接口。基于消息的通讯其视角直接是数据(消息)本身,不关心谁是客户,谁是服务器。给数据一个主题(常称作 Topic),数据生产者给数据标记主题并发送出来,数据需求这根据主题索取数据,一般称作“发布/订阅模式”。ppPednc
典型消息中间件协议及相关产品有 DDS 和 MQTT 两个体系。前者更强调高可靠性和实时性,尤其对数据通信的服务质量策略(QoS)有丰富的支持。后者强调低带宽占用,只需要极少的代码和有限的带宽就可以实现并工作,所以在物联网上得到广泛的运用。ppPednc
RPC 和消息通讯各有优势,往往被结合起来使用,SOME/IP 协议对这两者都有支持,详见下文 2.4.5 与 2.4.6 节,甚至还可以互相实现,详见 3.5.2 节。ppPednc
1.2 中间件的产品概念ppPednc
通过上面的介绍,我们可以看出,中间件的概念其实并没有一个统一的定义。大家讨论中间件的时候,都用这个词,但可能讨论的并不是同一件事情。"中间件"这个词本身是一个就是一个相对概念。在分层设计是软件架构中的一种典型做法。任何一层相对其上下两层来说都是"中间层"。ppPednc
通常意义上,我们讲的“中间件”是把特定应用开发所需要的一些共性技术或组件提取出来作为一个通用的产品,有这样的产品做基础,应用开发就会简单快速。ppPednc
中间件产品的具体功能是与不同的行业应用领域相关的。但是不同的行业应用领域,其特定软件技术,或软件设计模式又是高度相通的。所以我们在说起“中间件”时,有时候指的是某个特定行业的功能需求,有时指的是一个软件设计模式,有时又是某一项具体技术,比如说通讯通道或者序列化技术等。其概念经常是随着上下文飘忽不定的。ppPednc
为了后文的讨论更加精准,图 1.2 从“最小核心” “应用领域拓展”、“关键技术” 和“软件架构设计”几个角度对“中间件”这个概念做一个澄清。ppPednc
“最小核心”和“应用领域拓展”在这一章阐述。“关键技术”和“软件架构设计”内容较多,专门作为一章来表述。ppPednc
图中红色边框的部分,是本文讨论的内容范围,这些在自动驾驶相关的中间件技术中都会涉及到。ppPednc
ppPednc
图 1. 2 中间件的两个应用领域ppPednc
1.2.1 最小核心与两个应用领域ppPednc
“中间件”往往是“分布式中间件”这个概念的简写,所以中间件通常有一个狭义的最小核心,即在分布式领域中负责解决通讯问题。这个最小核心里又涉及两种通讯方式,一种是远程过程调用(RPC),一种是消息传递。RPC 有明确的服务接口定义,主要用于1 对 1 的通讯,消息传递更关注数据的主题与结构,不一定需要明确的服务接口定义,可以进行多对多的通讯。ppPednc
这两种方式并不是完全互斥的,实际上典型中间件产品或通讯协议两者都会兼收并蓄。比如 SOME/IP 协议的 Request/Response Communication 机制([7]4.2.2)相当于RPC,Event([7]4.2.4)相当于消息通讯。在实现上,我们也可以利用 RPC 机制去实现消息通讯,也可以利用消息通讯去实现 RPC 调用。“中间件”的最小核心关注的是数据通讯。ppPednc
图中列出了两个应用领域路线:ppPednc
1. 最小核心 → Web 应用框架 → 企业级应用中间件ppPednc
2. 最小核心 → 车载中间件 → 自动驾驶中间件ppPednc
这两个领域虽然在最小核心上是一样的,但是其领域拓展的技术方向差别很大。ppPednc
1.2.3 Web 应用领域ppPednc
第 1 条领域路线是从 Web 应用中间件到企业级应用中间件。要知道,最早计算机应用都是单机的,然后逐步变成分布式跨网络的。单机的计算机程序要运行,需要基本的资源,包括 CPU,内存,持久化存储(磁盘)。同样,Web 应用程序也需要相关的资源,下表是一个非严格意义上的对比,旨在显示二者的在抽象层面上的共性。ppPednc
ppPednc
这个表格并不算完整,它只是说明与单机应用相比,Web 应用只是在各方面资源和技术的上采用了分布式技术而已。原来单机程序由操作系统提供的支持,改由各种分布式技术来提供。这些技术的不同实现的组合,形成了 Web 应用的中间件系统。既然是分布式的,其内核自然少不了 RPC 与消息系统。ppPednc
在此基础上,继续叠加一下企业应用所需要的功能,如事务处理,工作流等等,企业应用往往有复杂的逻辑,有些被抽象成分布式对象来表达其数据和行为(面向对象思想在分布式系统上的延展),就又涉及对象的容器技术,对象的生命周期管理等等,这就形成了支持企业级应用的中间件系统。ppPednc
1.2.3 车载应用领域ppPednc
第 2 条领域路线是将中间件应用到车辆上,这时候关注的是实时性、可靠性,更多的总线类型支持、诊断的支持等等,这些对车载软件而言是基础功能,只要是装在车上的ECU 都需要。这些基础功能做好了,可以复用到所有车载 ECU 的开发上。因此叫车载中间件,也叫基础软件,典型的是 AutoSAR。ppPednc
当车载中间件被用于自动驾驶系统时,又会有一些特别的要求。比如需要能满足更高的带宽需求以支持传感器数据传输,对任务执行的时间确定性要求,对异构平台的要求等等。对这些要求的满足构成适用于自动驾驶的中间件系统。ppPednc
更进一步,为了能更快速的开发自动驾驶应用,如果把自动驾驶应用开发所需要的公共部分形成一个应用开发框架,让传感器适配、感知融合、规划决策、地图及定位、控制执行的适配等关键部分都有标准的开发模式并提供基础的实现,那么这个框架也可以叫自动驾驶的开发框架,是对车载中间件在自动驾驶领域的进一步扩展。ppPednc
ppPednc
中间件的关键技术
有时候我们谈“中间件”实际是在指其中的某个关键技术或者是软件设计模式。与中间件相关的技术点非常多,这一章我们列举出其中的主要部分,梳理其相互关系,并对每个技术点及其相关产品做简要说明。ppPednc
2.1 泛化的 RPC 概念模型ppPednc
先来看最小核心中“远程过程调用(RPC)”范畴内最主要的几个概念。图 2. 1 以 UML类推描述了几个概念之间的依赖关系,虚线箭头 A -----> B ,表示 A 依赖于 B。ppPednc
ppPednc
图 2. 1 RPC 相关概念依赖关系ppPednc
接口定义语言(IDL)规范描述了通讯接口定义的数据结构,调用的输入参数与返回值等等。用户程序需要根据这个规范先编写“应用程序的接口定义”。一般中间件产品会支持多种开发语言,需要为每一种语言提供对应的“代码生成工具”,根据接口定义生成该语言的代码。生成的代码典型地包含两部分,一部分是服务端的代码桩(stub),代码桩定义了语言特定的软件接口,用户继承代码桩并提供具体的功能实现。另一部分为代理(Proxy)代码,服务的使用者通过代理代码访问远程的 RPC 实现。代理代码对用户屏蔽了实际的通讯细节。ppPednc
实际的通讯细节由“中间件运行时(Runtime)”来执行。中间件运行时不仅仅要负责实际的通讯过程,还需要设计合适的任务调度机制,线程模型来保证 RPC 请求和处理的高效,给用户提供的 API 接口简洁易用。ppPednc
“中间件运行时”需要采用合适的“数据序列化机制”来保证用户定义的复杂数据结构能够在“通讯通道”中传输,中间件运行时还需要能支持多种通讯通道来让中间件可以被应用到更广泛的场景。“通讯通道”往往需要符合特定的“通讯协议”规范。 ppPednc
ppPednc
2.2 接口定义语言ppPednc
2.2.1 IDL 示例ppPednc
我们先来看一个接口定义的描述ppPednc
ppPednc
这是来自 GENIVI Common API 的一个例子。ppPednc
这里使用的 IDL 语言规范是 GENIVI Franca[10][11]. 这个接口描述里定义了一个名为E03Methords 的 RPC 接口,定义了一个名为 “foo” 的方法,它包含有两个输入参数,分别为 Int32 和 String。“foo” 方法还有两个返回值。还有一个表示方法调用成功或失败的枚举类型。ppPednc
接口描述里还包含了一个表示广播数据的 muStatus 类型,广播一个 Int32 类型的数据。ppPednc
下面的代码示例是 gRPC 的接口定义,gRPC 采用 google protobuf 作为接口定义语言。示例中定义了一个名为 Greeter 的服务接口,包含一个SayHello 方法,指定了输入和输出的数据类型。ppPednc
ppPednc
CORBA 遵循 OMG 组织的 IDL.ppPednc
ROS 也有自己的消息格式规范.ppPednc
Apache Thrift 的 IDL 规范.ppPednc
Adaptive AutoSAR 的 IDL 是在 ARXML 格式规范里,这个格式不适合手动编写,一般使用特定的工具来编辑。ppPednc
Web Service 也有自己 IDL 标准,称作 Web 服务描述语言(WSDL).[15]ppPednc
图 2. 2 以 SysML 需求图的形式列举了典型的 IDL 所需要包含的能力。可以归结为“数据类型支持能力”和“接口描述能力”两部分。ppPednc
ppPednc
图 2. 2 IDL 能力需求ppPednc
ppPednc
2.2.2 数据类型支持能力ppPednc
一般来说基本数据类型都会得到支持,即各种长度的整数、单/双精度浮点数、布尔值,字符串这些。枚举类型往往也会支持。ppPednc
自定义数据结构的能力是 IDL 必须提供的,这是用户定义自己应用所需数据结构的基础。复杂数据类型支持一般包括“数据结构的嵌套”以及“集合类型”。有的会支持数据类型的继承。ppPednc
ppPednc
2.2.3 接口描述能力ppPednc
接口描述能力一般可以表达为类似一个函数的定义,包括函数名称,输入参数,输出参数。参数一般支持自定义数据类型和复杂数据类型,有的可以支持多态,但映射到目标开发语言时会有一定难度。ppPednc
接口的方法一般默认是双向调用,意味着会有返回值。绑定到特定开发语言的时候,代码 API 如何获取返回值是一个需要精细设计的问题,涉及到同步调用和异步调用的问题,下文详述。ppPednc
单向调用表示请求者只是发出对远程的操作,但是不关心返回值。这个能力有的 IDL规范会在 IDL 描述中指定,如 Franca: ppPednc
fifireAndForget 关键字就是代表单向的含义。ppPednc
有的产品不在 IDL 中指定单向还是双向,但是代码生成时两个调用方式都在支持,在使用时有用户自己选用,如 Zeroc ICE 的 oneway 代理。ppPednc
在 IDL 中做单向声明会更准确的表明的服务接口的语意,便于代码生成和运行时做优化,也能在服务实现时根据这个语意做明确的处理。ppPednc
QoS(Quality of Service,服务质量)是一个比较大的话题,基于 RPC 的中间件很少在 IDL 中描述 QoS 要求,往往在代码中体现。后文详述。ppPednc
ppPednc
2.2.4 广播与属性ppPednc
发布订阅的设计模式能够有效的降低软件系统中各部分的耦合。基于消息中间件天生就是基于发布订阅的模式来设计,如各种 DDS 的实现。基于 RPC 的通讯中间件早期并没有明确的广播消息支持,但是也有其它方法实现发布订阅模式(后文详述).ppPednc
AUTOSAR 服务模型将服务定义为提供的方法、事件和字段的集合[16]。这是要求通讯中间件明确支持事件的广播([14]3.4.2 节)。GENIVI Franca[10][11] IDL 通过 broadcast 关键字来支持。ppPednc
服务或接口的 “字段” 也叫属性(Field, Attribute ) 可以读取、设置、和广播,后文详述。ppPednc
ppPednc
2.2.5 高级特性ppPednc
有些特定的 IDL 规范还会提供一些独特的功能。比如 gRPC 提供了流式的输入输出方式。ppPednc
ppPednc
客户端可以连续发送多个请求,服务端可以连续对多个消息进行响应,这充分利用底层的数据通路,提高整体的响应速度和传输吞吐量。ppPednc
Franca IDL 提供了对状态机的定义方式,称作协议状态机(PSM),如:ppPednc
根据 IDL 生成的代码中也能根据定义的状态支持对应的转换机制和对服务请求的约束。很多自动驾驶功能有自己的状态机设置,很适合使用这种方式来描述。ppPednc
ppPednc
2.3 通讯通道与通讯协议的可替换性ppPednc
通讯通道和通讯协议是我们常说的名词,这两个概念相关性很强,有时候指的是同一件事情,有时候又有些差别。这里先做一些澄清。ppPednc
网络协议一般都是分层结构,ISO/OSI 参考模型定义了七层,从下往上依次是物理层、链路层、网络层(IP 层)、传输层(TCP,UDP)、会话层、表示层和应用层。实际互联网使用协议栈并没有会话层和表示层,这两层的功能往往会由各自程序的应用层处理。ppPednc
每一层的协议有不同的实现方式,这些不同的实现对于上层协议来说,就是不同的通讯通道。比如,网络层(IP 层),其底下的通讯通道可以是同轴电缆构成的以太网,也可以是 WiFi,甚至可以是 USB。每一种通道有其特定的物理层和链路层协议。ppPednc
车载以太网常用的 SOME/IP 协议实际是应用层协议,它是基于 TCP 或 UDP 的,HTTP 协议也是应用层协议,它是基于 TCP 的。实际上 HTTP 协议在互联网领域已经变成了一个通用的通讯通道(或者通讯方式)的代称,还有很多协议是基于 HTTP 实现,比如Soap 协议,gRPC 的数据传输协议。ppPednc
所以通讯通道和通讯协议是相对的,要根据上下文去判断。ppPednc
车载的 SOA 应用的通讯目的是实现不同服务之间的数据交互,其底下的通讯通道可以是使用 SOME/IP 协议的以太网,也可以是共享内存,还可以是 DDS,不同通道有不同的协议实现方式。而 DDS 本身也可以基于以太网或共享内存。不同的设计方式各有优缺点,要根据实际需要选择。ppPednc
IDL 只负责服务接口的定义,用户代码只有与生成的代码和 Runtime 接口进行交互,一般不用直接和通讯通道与通讯协议交互。这就为 Runtime 替换不同的通讯通道和通讯协议提供了可能性。ppPednc
Adaptive AutoSAR 通讯管理规范就明确提出要求:通讯实现不绑定到特定的通讯协议,SOME/IP 协议是必须支持的,但要求能替换成其它协议([16]P10)。实际上各家Adaptive AutoSAR 厂家的产品,往往会在 SOME/IP 协议外,支持 DDS 或共享内存的通讯方式。如华为的 MDC 平台使用了自研的 Adaptive AutoSAR,就对 SOME/IP、DDS 和共享内存都能够支持。ppPednc
图 2. 3 是 Apache Thrift 的概念图,下面两层中,Protocol 负责序列化,Transport 层负责实际的数据传输通道,默认一般会支持 TCP 传输,TCP 数据的协议格式是 Thrift 私有格式,Thrift 文档中并不强调这个协议格式,各语言的运行时采用一样的协议格式就可以互相通讯。用户可以做自己的 Transport 层实现,比如实现共享内存的通讯。ppPednc
ppPednc
图 2. 3 Thrift 序列化协议与传输通道ppPednc
Genivi Common API 也可以绑定不同的通讯通道和协议,目前已经支持的有 d-bus 和 SOME/IP。与 Thrift需要用户在代码上指定使用哪种通道不同,Common API 用户
编写代码时不需关注底下绑定的是那种通讯通道和协议,应用部署时可以通过配置文件指定,程序运行时根据配置文件动态加载指定的通讯通道。
图2.4来自Common API 文档,libCommonAPI-xx.so 是通讯通道绑定实现,可以有多种实现,程序启动时加载配置文件指定的那个。当然,用户可以自行开发更多的通讯通道和协议,比如共享内存或DDS。
2.4 SOME/IP协议
SOME/IP,全称为Scalableservice-Oriented MiddlewarE over IP,是由BMW集团提出的,后来进入AutoSAR 标准[7]。伴随着SOME/IP协议一直有几个标签:
这里我们以这几个标签为出发点,尝试讲清楚以下几个问题:
-
为什么需要SOME/IP协议,或者说需要它解决什么问题
-
SOME/IP 是什么,不是什么,或者说它定义了什么,没有定义什么
-
-
2.4.1 为什么需要SOME/IP协议
汽车内部通讯最常用的就是 Can总线,但是Can总线的局限性也很明显:
-
速度低,普通Can <500kbps, 高速Can 1Mbps, Can FD 5Mbps
-
-
报文有效数据长度太小,普通Can 8字节,Can FD 64 字节
Can FD 是在能与传统Can协议兼容前提下做的扩充,但架不住汽车上功能越来越多越来越复杂,尤其是智能座舱,智能驾驶,OTA 等相关功能的引入,需要更快速、支持大量数据传递、更能适应复杂汽车软件架构的通讯协议。针对上面 Can 总线的局限,SOME/IP 协议提供了至少以下几方面的能力提高。ppPednc
-
速度提高:基于以太网,典型车载以太网有100Mbps,1000Mbps的以太网也很常见,尤其是在智能座舱和智能驾驶的域控制器中,千兆以太网是标配。将来随着光纤以太网的应用,速度达到1G~10Gbps 也不会太远。
-
报文长度扩大:基于UDP协议时,SOME/IP 报文的长度最大可以有1400字节,超过1400字节,可以使用TCP协议。Classic AutoSAR 有一个 SOME/IP TransportProtocol[18],这个协议支持对超过1400字节的报文进行分隔传输。
-
SOME/IP 协议的设计上引入了面向服务的概念,有利于各种车载应用的模块化设计和互操作。
2.4.2 SOME/IP是什么ppPednc
SOME/IP 核心是两个协议,一个用于服务之间进行数据交互(称作SOME/IP协议[7]),一个用于服务发现(称作SOME/IP-SD协议)。协议内容包括:
-
每个协议有一个报文格式,SOME/IP-SD的报文格式是基于SOME/IP报文格式
-
这两个协议是定义在 AutoSAR Foundation 部分。
这意味着,Classic AutoSAR 和 Adaptive AutoSAR 都应该支持这个协议。
SOME/IP 核心就是这两个报文格式以及数据交换的一些时序约定。Zeroc ICE、gRPC、Thrift这些完整中间件产品,往往不会特别强调自己的应用层协议格式细节,一般也不会给出明确的应用层协议文档。
当然 SOME/IP这两个协议的设计细节还是非常精巧的,这也是它能够支持所谓的面向服务架构的基础。这里先不说报文格式的细节,后面会进一步提到。
2.4.3 SOME/IP 不是什么
正因为只是以非常精简克制的方式,仅仅定义了数据交换的报文格式,这意味着不同的中间件产品只要基于这个协议就可以互操作。各自产品在其它方面进行各自的比拼,比如性能,易用的API等。下面我们来说说 SOME/IP 不是什么。
前面已经说到,SOME/IP 不是完整的中间件产品,完成一个中间件核心功能的还缺少很多方面的东西。
2.4.4 SOME/IP与以太网ppPednc
UDP 还是 TCP
既然SOME/IP 底下的通讯协议可以是UDP或 TCP,那么什么时候该用什么底层协议,有什么差别?我们先看看 UDP和 TCP 的区别
可以看到TCP是可靠传输,保证数据到达的顺序,但是不支持广播,只能一对一连接。SOME/IP 底层通道如果使用TCP协议,带来的直接便利就是可以在一个 request/response 动作中传输大量数据,比如一次把 2MB的图像数据传递出去。理论上这么做没有问题,但是实际应用中很少这么做。原因在于以下几个问题:
-
TCP 有一个连接建立的时间,根据请求和接收端的距离,服务器负载情况,网络负载情况,时间不定,从零点几毫秒到几百毫秒不等。如果每次“请求/响应”动作不能共享同一个连接,就每次都要新建一个连接。
-
TCP数据传输时,有一个“慢启动”的过程。因为TCP协议栈不知道当前物理通道的实际带宽是多少,它会以一个较低的速度发送数据,如果丢包率很低就逐步提高速度,当丢包率提高,确认时间变长就再降低速度,最后稳定到一个合适的传输速率。
-
TCP 只能一对一连接。SOME/IP 协议中有 Event 和 Field 消息类型。请求者可以要求订阅 Event消息 或 Field 的变化。如果使用TCP,要实现这个要求就需要服务提供者向多个订阅客户端每个都发起一条TCP连接,数据也要发送多次。
这些问题导致每一次数据传输的时间并不稳定。比如说我们要在一个千兆bps的以太网上传递每帧3MB 大小的图像数据,理论上只需要24毫秒,但是因为上面的原因,这个时间可能会在24~100毫秒的区间内抖动,而且还会有累积的延迟。这对视频播放类应用没有太大影响,但是如果用于自动驾驶应用中传递摄像头数据,如果我们要求稳定的30FPS的帧率,就无法保证。而且 TCP的实现是直接在 OS 内核协议栈中实现的,用户层代码也没有太多的进行改进的空间。
如果使用 UDP,这些问题就可以避免,不过要约束一下数据报文的大小。使用UDP:
-
不用建立连接,直接发送数据ppPednc
-
发送速率没有约束,但是接收方收不到会丢弃,需要发送方选择一个合适的发送速率ppPednc
-
不保证顺序,那就让SOME/IP 报文不超过一个UDP报文的有效载荷大小,就不用把SOME/IP数据在UDP层拆成多个包。ppPednc
-
UDP不保证可靠到达,那就要在SOME/IP 的协议实现层来做错误处理,比如重新发送SOME/IP请求ppPednc
-
UDP 可以支持广播或多播,适合用来支持 Event 和 Field 类型的数据传输。
根据上面的分析,在车载应用中,绝大部分场合,我们都应该使用基于 UDP 的SOME/IP,并控制每一次消息传递的大小在 1400 字节以内。那么图像数据远远超出了这个大小范围,应该怎么办,后文会提到其它的解决办法。
关于UDP数据包长度
SOME/IP 在UDP通道下有效载荷最大1400 字节。这个数字是在 AutoSAR SOME/IP 文档[7]中出现的。尝试还原一下计算方式,在以太网链路层,由以太网的物理特性决定了数据帧的有效载荷最大为1500(不包括帧头部和帧尾部)[19],术语叫做MTU(Maximum Transmission Unit)。网络IP包的首部要占用20字节,传输层UDP头部要占8字节,SOME/IP头部又需要32字节。所以剩余的有效载荷为1500-20 – 8 -32 =1440字节。这个比 SOME/IP 文档描述的多了40字节,没查到这40字节被用到了哪里,也许只是 SOME/IP 协议直接限定1400字节,留了一个余量。
另外,UDP 的报文大小限制是64K,所以并不是UDP装不下超过1400 字节的SOME/IP报文。这个1400限制的意义是它可以被装入到一个IP报文内,也可以被装入到一个链路层数据帧中。IP 层不需要对UDP报文分割重组,链路层也不需要IP层分割重组。这样带来的好处是错误重传的几率降低,也意味着一个 SOME/IP 报文传递的时间更稳定,这在实时性要求高时很有意义。
需要注意的是,并不是说你把SOME/IP报文限制在1400字节以内,IP层和数据链路层就不会进行拆包再重组了。因为 MTU值在不同环境下是不一致的。1500是 IEEE 802.3协议指定的[19],但是你使用的网络设备可能指定了更低的值。Internet 上标准的MTU是576字节[20]。很多路由器或网关上的 MTU值也是这个设置。所以要确认你的SOME/IP报文不会被拆成多个网络或链路层报文,最好追溯并确认底层协议栈的设置。
从另一方面讲,某些链路层的MTU大于1500,也意味着理论上可以增大SOME/IP报文的大小。如在FDDI中,MTU为4352字节;在 IP over ATM中,MTU为9180字节。
TCP/UDP 之外的其它选择
TCP与 UDP是在几十年前设计的,当时以太网速度慢、延迟长、错误率高。尤其是TCP协议很多特性都是为了在网络基础设施不理想的情况下保证可靠性。而且这几十年的协议发展速度非常的慢。
SOME/IP 协议文档中虽然说了传输层基于TCP或 UDP,但实际工程上,使用其它协议也无不可。在传输层,为了解决 TCP 协议的一些问题,也发展出一些新的协议。如 SCTP 和 QUIC。
SCTP 协议全称 StreamControl Transmission Protocol[21]。SCTP 是一种新的 IP 传输协议,与UDP和 TCP处于同等级别,为应用程序提供传输层功能。与TCP 一样,SCTP 提供可靠的传输服务,确保数据在网络上按顺序无错误地传输。与 TCP 一样,SCTP 是一种面向会话的机制,这意味着在传输数据之前在 SCTP 关联的端点之间创建关系,并保持这种关系直到所有数据传输成功完成。
但是,相比 TCP,SCTP至少有两个对SOME/IP友好的特性:
-
支持在一个连接上同时进行多个数据流程的传递(TCP只允许一个),这可以让 SOME/IP通过一条连接在多个流上同时发起并发的请求。
-
以数据块为单位传输的(TCP是以字节为单位),让 SOME/IP把一次 RPC调用包装在一条数据块中
另外,SCTP 提供了更优化的拥塞控制策略,提高了传输的效率。
但是 SCTP 仍然有较繁琐的连接建立过程(改善了安全性)。这方面,QUIC协议有更好的表现。
QUIC全称Quick UDPInternet Connection。是基于UDP实现的可靠数据传输协议。QUIC 协议的主要目的,是为了整合 TCP 协议的可靠性和 UDP 协议的速度和效率。相对TCP它有如下重点优化:
-
简化了连接的建立过程,加快了连接的速度ppPednc
-
支持一个连接中多个传输流ppPednc
-
这些特性让 QUIC 成为 TCP 的最好替代者。工程实践中可以考虑采用。HTTP3.0协议就是要求底下采用QUIC协议。
但是在需要广播或多播的场合,还是只能使用 UDP。
其实SOME/IP 协议的下层通道甚至可以不使用以太网,比如采用SPI。车载域控制器中往往为了实现功能安全会在SoC之外配一个满足ASIL-D规范的的MCU,如图2.7:
SoC 与 MCU 之间除了以太网连接外,还有 SPI 作为冗余备份。这种情况下,可以在SPI驱动中实现对 SOME/IP报文的传输。应用程序只需要使用 SOME/IP 协议,而不关心底下实际的数据通道。
2.4.5 SOME/IP 与RPC
我们结合 SOME/IP 的协议来看它对 RPC的支持。报文格式如下:
其中32位的 Message ID 由两部分组成,一个是Service ID,一个是 Method ID。假如以Franca规范定义的一个服务 HelloWorld的定义如下,其中包含了一个方法sayHello,
version {major 0 minor 1 }
当这个服务接口与SOME/IP进行绑定时,就需要指定其Service ID 和 Method ID(此时,报文中的Message Type 为 0x00或 0x01)。如下:
define org.genivi.commonapi.someip.deployment for interfacecommonapi.examples.HelloWorld {
SomeIpStringEncoding = utf16le
如果我们使用Thrift 或 gRPC的时候,是不需要这么显式的进行Service ID 和 Method ID的声明的。因为Thrift 或 gRPC是作为独立的产品存在,Service 和 Method 的识别机制是各自的协议内部实现了,在代码生成时已经为我们处理好了。用户不需要直接进行指定。而SOME/IP只是一个纯粹的通讯协议,两个不同厂商的SOME/IP实现也是可以相互通讯的,所以其Service 和 Method的ID生成规则不能由各自的实现库自己指定,而是需要将ID的指定能力暴露给用户来确定。
2.4.6 SOME/IP 与消息通讯
当SOME/IP 报文中的MessageType 为 0x02时,代表是一个Event,这时候的报文是基于消息通讯中的一个消息报文,不需要回复。报文中的 Method ID此时为 Event ID。
例如以Franca规范定义的一个服务 MyService的定义如下,其中包含了一个事 件myStatus,
version {major 1 minor 2 }
绑定到SOME/IP时需要为其指定 EvernID。
define org.genivi.commonapi.someip.deployment forinterface commonapi.examples. MyService {
SomeIpEventGroups = { 33010 }
EventGroup 的定义会被服务发现报文使用,这里不作详述。
当以UDP报文发送事件消息时,可以使用UDP的多播机制,同一个多播组的侦听者都能收到消息,这是SOME/IP消息通讯的基础实现形式。
基于DDS和MQTT的消息中间件实现会比这个更复杂一些,基于UDP的多播只是它们实现组内广播的一种物理形式,在不同的网络环境中有不同的实现方式,也可以在某个局部网络采用存储转发的方式,还有更多的QoS支持。
但SOME/IP的消息通讯仅仅定义了一个广播报文形式与消息发现机制,协议本身除了UDP多播外不涉及其它多播实现方式,也不涉及QoS,具体的实现可以进一步扩充这些特性的支持。
2.5 共享内存及零拷贝
2.5.1 共享内存加速的必要性
中间件技术带来了软件架构上的便利,让用户可以把复杂的功能拆解成多个不同的服务,协同工作。但是也带来另外的问题,就是通讯的延迟。下表展示了计算机系统中不同形式的数据传输所需要的时间。可以看到,一次主内存访问的时间约100纳秒,但是如果数据在二级缓存中,访问速度就提高了一个数量级。从主内存读取1MB数据,约60000纳秒,但是通过千兆以太网传输,速度就下降了至少两个数量级。ppPednc
ppPednc
也可以用更直观的方式来看,如自动驾驶典型使用的200万像素摄像头,分辨率1920x1080 以YUV422格式记录,大小月4MB。通过千兆以太传递,需要时间约在35毫秒左右,这意味着每秒最多只有30帧。如果是800万像素的摄像头,最多只能到每秒8帧。但是如果通过内存传递数据(DDR4,17GB/s),200万像素可以达到4000FPS,800万像素可以到 1000FPS,差异巨大。
所以在利用中间件技术带来的架构便利的同时,要考虑如何利用共享内存技术进行通讯的加速,这在自动驾驶产品开发中尤其重要。
2.5.2 具体技术问题及冰羚(iceoryx)介绍
最近博世推出的开源中间件产品iceoryx[25]受到较多关注。不过Iceoryx并不是我们在第二章所定义的完整中间件概念。它专注于共享内存技术,但是设计良好的API可以使它能比较方便的与其它中间件进行集成,如 Adaptive AutoSAR,ROS等。这一节我们结合冰羚简述共享内存技术需要解决的问题。
共享内存技术是IPC(Inter-ProcessCommunication,进程间通信)的一种。
注:IPC只是一个统称,除了共享内存外,进程间的同步锁,信号量,名字管道,消息队列,网络Socket 等都是IPC。但是在涉及到中间件的文章中,因为中间件本身就是主要用于分布式场景,通过网络进行通讯,往往提起 IPC 的时候实际指的就是共享内存技术。严格来说这并不严谨,所以本文中的 “IPC” 指的就是其原意--- 任何可以进行进程间通讯的技术。如果读者在其它地方看到“IPC”,请注意根据上下文分辨其实际含义。
进程地址空间与共享内存
进程是操作系统中非常重要的基础概念之一,每个进程都有自己独立的虚拟地址空间,互相之间不能跨界访问。操作系统负责把每个进程的虚拟地址空间映射到实际的物理内存,如图2. 9。
为了实现进程间通过内存的数据共享,操作系统至少要提供两类系统调用。一个是将本进程的虚拟地址映射到物理内存区(创建共享内存),一个是提供进程间的同步机制。
同步机制是必须的,共享内存区对于进程A和进程B是一块竞争资源。如果同时进行读写访问会产生不可预料的错误。进程A写入数据完成后,需要有机制通知进程B可以读取。进程B读取完数据后,也需要告诉进程A可以写入新的数据。
同步机制可以使用操作系统提供的互斥锁、信号量等同步原语。所以,可以认为,冰羚提供的最基本的功能就是对操作系统共享内存操作函数和同步机制的封装。可以使用简便的API(C或 C++)进行上述操作,而不用理解操作系统同步原语的细节。
多帧数据缓存、流控、发布订阅
一般来说,共享内存最简单的使用方式就是两个进程映射同一块内存区到自己的虚拟地址空间后,进程A写入数据,完成后通过同步机制唤醒进程B读取数据,进程B使用完数据后,通知进程A写入新数据。可以认为这是一个单帧的数据交换。
然而实际应用场景远比这复杂。试想一个低速场景下的环视自动泊车应用。四个环视鱼眼摄像头采集视频数据,同时有4个模块需要以不同的方式使用这些摄像头的数据:
-
“环视拼接算法”需要将4个摄像头的画面进行畸变矫正后拼接成一个顶视图画面,为了人观看流畅,需要至少30fps 帧率ppPednc
-
“障碍物检测算法”需要检测画面中的障碍物,因为是低速场景,要求15fps的帧率ppPednc
-
“停车位检测算法”需要检测画面中的划线停车位,需要10fps 的帧率ppPednc
-
“行车记录仪”将画面编码保存为视频数据,需要20fps 的帧率。
图2. 10环视泊车应用共享内存示例
这里我们可以看到有几个难点:ppPednc
-
产生的数据是连续的多帧ppPednc
-
有多个消费者ppPednc
-
一个能够容纳多帧数据的缓冲区以及对应的缓冲区管理机制是必须,数据在一个帧缓冲区填写好后,不用等消费者用完,就可以在另一个缓冲区中写入新数据,这可以大大提高系统的数据吞吐量。但是到底保留多少个缓冲区?数据消费者读取太慢,所有缓冲区都满了,那么是放弃新数据,还是阻塞写入者,让数据生产得慢一些?这就涉及到缓冲区管理和流量控制的机制了。这也是我们使用共享内存的时候常遇到的问题。冰羚内置了多帧缓冲区管理和流量控制的能力。
当有一个帧内存被多个消费者使用时,冰羚内部实现会对每一个帧内存维护一个计数器,表示当前的消费者数量。消费者释放这一块内存区时,计数器减1。计数器为0时,该内存区可以用来写新数据。再上面的例子中,极端情况下,三个较慢帧率的数据消费者进程(行程记录编码,停车位检测算法,障碍物检测算法)各锁定了一个帧内存区没有释放,但只要还有两个帧内存区,一样能保证拼接算法以更高的帧率获取数据而不会被其它进程阻塞住。当然,更多的帧容量能防止消费者计算时间的抖动,效果更好。
前面的例子中,一个数据生产者,有多个数据的消费者,典型的实现机制是使用操作系统提供的信号量同步机制,让多个消费者等待新数据的到达。冰羚提供了一套设计精巧的发布订阅机制API,封装了底层的进程间数据同步机制。
零拷贝
前面说到,200万像素摄像头每帧数据4MB,如果每秒30帧,就有120MB的数据。如果多个消费者都需要拷贝数据到自己的缓冲区,那么每秒钟会有几百MB的数据在传输,大量消耗内存带宽和CPU资源,也消耗了额外的内存空间。
最好的解决办法就是在这块共享内存区中,数据生产者写入,数据消费者读取,数据就在原地,不需要做额外的复制。这就是所谓的零拷贝。
道理很简单,但是真正实现起来还是有很多细节技术。一方面是消费者和生产者之间的同步机制,前面已经讲过。其实多帧缓存也跟为了达到零拷贝的目的相关。不同消费者可以同时锁住一个数据帧的内存,同时使用数据进行处理,因为它们各自的处理速度不一样(帧率不同),一个消费者已经完成处理并释放数据帧内存区,而另一个仍然占用。所以需要在另外的帧数据区中读写新的数据。
另外,API形式上也有一些技巧。对与C语言,比较简单,一般是直接将一个共享内存地址转换成C Struct 的指针再对这个 Struct 进行读写。C++ 稍微复杂一些,我们需要把 C++ 的对象放在共享内存区。C++的 new 操作符实际有两步动作:
1.分配内存ppPednc
默认的内存分配是在堆中进行,我们可以单独重载new 操作符的内存分配部分,让它从共享内存中分配。或者直接给new 操作符提供共享内存中的某个地址,再这个地址上调用构造函数(一般称为placement new)。
以上这些为实现零拷贝所需要的技术在冰羚中都有实现,提供了很好的 API 接口。C++的STL是很难在共享内存中使用的,因为很多STL类型自带了内存分配机制。为了解决这个问题,冰羚还提供了一套类似STL形式的容器类型,可以在共享内存场景中使用。
2.5.3工程上的其它难题
冰羚这样的共享内存库让使用共享内存非常的方便。而且它的API是非侵入型的,可以比较容易与其它软件库集成。但是在实际工程中还是在某些场景依然有一定难度。
有些现有的程序库已经有自己的缓冲区管理和对零拷贝的设计,如 V4L (Video for Linux,常用于摄像头数据采集),与冰羚集成需要协调两者数据缓冲区的使用机制。
自动驾驶使用的高性能AI芯片往往是异构系统。除了有 Cortex-A核心外,还有R核心,M核心。还会有ISP处理单元,DSP处理单元,NPU等。并不是都运行Linux系统。典型的如 TI TDA2/TDA4 系列,是在其中的 Cortex-M核心或Cortex-R核心上运行RTOS系统,执行摄像头数据捕获动作。这种情况下依然要使用共享内存,并支持零拷贝,就需要根据芯片本身提供的能力基础上,再做较多的工作。
为了达到更高的算力,很多高性能SoC芯片都开始支持单板上放多个芯片,并通过PCIe接口连接数据通路(如:TDA4,征程5)。PCIe能提供超过20GB/s的带宽,远超千兆以太网。多个SoC芯片通过PCIe互联,操作系统的PCIe驱动可以支持多个芯片通过内存访问的方式进行数据交互,还可以通过DMA技术减少CPU负载。
理论上,冰羚这种软件库可以进一步扩展,基于PCIe支持跨芯片和OS的共享内存机制,在具体实现上需要与PCIe驱动在缓冲区管理,同步机制上进行适配。如果可以做到,就可以对上层屏蔽PCIe相关的操作,让多个芯片/OS之间基于共享内存的数据交换时跟单个芯片/OS在API接口上仍然保持一样简洁,可以大大简化应用层的开发。
另外,Android 从4.0 开始引入了新的内存分配管理机制 ION,目前已经进入的Linux 内核主线。它被用于在用户空间的进程之间和内核空间的模块之间进行内存共享,可以实现零拷贝的数据运用。尤其对摄像头数据采集、显示输出等涉及大量数据传输的场合非常有用,同样适用于自动驾驶领域的摄像头数据处理。ION 在内核空间和用户空间分别提供了一套可以相互协作的 API 接口。冰羚如果要更好的应用与自动驾驶领域,可以考虑与ION 的集成。
2.6 数据的序列化
程序使用的数据在内存中有其存在的形式,往往跟所使用的程序语言对数据的表示形式相关。最简单的是一个不含指针的C语言结构体表示的数据,其内容就在一块连续的内存区间里。如果结构体中包含了指针,那么有可能一部分数据在栈上,一部分数据在堆上。如果使用C++STL中的容器来保存数据,数据也不会在一个连续的内存区,STL还支持各种内存分配器,内存布局会更复杂。对于Java、C#、Python等具有垃圾收集机制的语言来说,用户不应该知道数据的具体内存位置,一个包含多个字段的数据类型其数据几乎不可能在一个连续的内存区中。
当我们需要存储数据或者在通讯线路上发送数据时,我们需要把内存中的数据结构转换一段连续的表示形式,可以是一段连续的二进制数据,也可以是一段连续的文本,这个过程叫序列化。反过来,将一段连续的二进制数据或连续的文本,转换成内存中的数据结构,就叫反序列化。程序之间要通过网络进行通讯,就离不开序列化和反序列化操作。
序列化有两个关键的衡量指标,序列化过程的速度和序列化结果的大小。如果序列化动作很频繁,就希望速度快一些,如果对网络传输速度更看重,就希望序列化结果小一些,也就是在时间和空间中寻找平衡点。
也有从可读性来考虑序列化的方式,一般来说,序列化成文本(如:JSON,XML等)结果会比较大,但是人直接可读;序列化成二进制会比较小,可读性极差。
Google 的 ProtoBuf是广泛使用的序列化库,性能和大小都得到很好的优化。更重要的是数据类型使用专门的IDL规范来定义,程序中用来执行序列化和反序列化的代码可以使用工具根据IDL文件自动生成,而且支持多种语言。这样就大大简化了开发工作。
通讯中间件产品都会有自己的序列化和反序列化协议,用来定义不同的数据类型如何转换成连续的数据表示形式。有的直接使用ProtoBuf,有的有自己的默认实现,也可以由用户自定义来进行扩展。
2.7 异步IO与任务调度
分布式中间件必然涉及到大量的网络I/O 操作。为了保证I/O 操作不阻塞用户线程的执行,中间件对异步I/O的支持就非常重要。中间件对异步I/O 的支持体现在两个方向,一个是如何充分利用操作系统提供的异步I/O机制(如Linux 的 epoll),一个是如何提供方便的程序语言特定的API。
操作系统提供的异步I/O的基本能力及相关的系统调用,各语言都有自己的异步I/O库来给用户提供更好用的API接口。
图2. 11列举了用户代码、中间件Runtime、异步I/O库以及操作系统接口之间的关系。例如:Thrift 的C++ 版本基于 libevent库实现,gRPC的C/C++ 实现使用了libuv,而Java 实现使用了 Netty。
中间件在这里其了一个作用,就是不让用户直接使用异步I/O库的代码,用户进行数据通信时直接访问代码是中间件根据IDL定义生成的代码。用户不需要了解太多异步I/O编程的知识,这些复杂性由中间件Runtime和生成的代码来处理。
同时中间件 Runtime 还要处理与异步I/O相关的线程模型和任务调度机制,在下文4.3.3 节有更详细的讨论。
2.8 QoS
服务质量策略(QoS)是分布式通讯中一个比较重要的概念。一方面通讯通道会有各种现实的物理约束,比如数据传输会出错,带宽有限,带宽会有波动,通讯会有延迟、拥塞;另一方面通讯参与者对传输的及时性、可靠性的需求是不同的,不同类型数据的重要性也不一样。
QoS 用于为不同类型业务提供区别性的服务策略,给那些对带宽、时延、时延抖动、丢包率等敏感的业务提供更加优先的服务等级,使业务能满足用户正常、高性能使用的需求。
可靠性(Reliability)
这个 QoS 特性涉及到在可靠和高效之间的平衡。最可靠的情况是“保障所有数据按照顺序被接收到”,丢失的数据会被重发,这样必然会承受效率上的损失。最高效的情况是发送方尽力按顺序发送数据,不管接收端是否收到,接收端自己重新排列收到数据的顺序,并要清楚的知道丢失的数据已经无法再获取到。接受端要能够对此进行相应的处理并保证程序的正确性。
在这两个极端之间,还可以有折中的方案,比如最后几个数据保证完全可靠,其它数据采用最高效的方式;或者说需要严格按照顺序接收,但是允许丢失部分数据。
截止时间(Deadline)
发送者承诺在一个Deadline 时间内发送数据,接收者希望在一个 Deadline 时间内获得数据,这个Deadline 应该大于等于发送者的 Deadline,否则会产生不匹配的错误。发送或接受超过了Deadline 时间,需要进行错误处理。
重试次数(RetryCount)
这是一个故障恢复的特性,当出现传输错误时,可以自动进行多次重试。但接收端需要处理收到重复数据的问题。
这只是最简单的几个 QoS特性,商业版的RTI DDS支持的QoS特性至少有40个以上。中间件对QoS的支持是很有挑战的工作。其难度一方面在于众多的QoS特性需要设计、开发、测试,工作量很大。另一方面在于不同的QoS其实差别非常大,涉及到通讯中可靠性、性能、安全、数据持久化等等各个方面,还会有新的QoS特性会被提出来,如何设计好一个合适的、能对多种多样QoS特性进行支持的软件架构就很有挑战。
2.9 多语言支持
多语言支持是中间件的一个关键特性。增加一个语言支持主要是两方面工作,一个是开发基于该语言的中间件Runtime 实现,一个是开发代码生成工具,根据IDL生该语言的代码桩。
中间件的功能越复杂,特性越多,Runtime实现的难度就越大。一般中间件都会先实现C/C++版本,其它语言可以只实现对C/C++版本的API封装,这样降低工作量,同时也能获得与C/C++版本接近的性能。
语言特定的代码生成工具也是多语言支持的重要部分。代码生成过程一般分为两大阶段,第一阶段是通常程序语言编译时都会有的词法分析、语法分析、语义分析过程,得到的结果是抽象语法树;第二阶段是根据得到的语义分析结果,生成目标语言代码。
当中间件要支持多语言时,第一阶段的工作对各语言而言是共用的,只是第二阶段要为各语言单独编写。
Thrift 就是基于 Lex/Yacc 库实现第一阶段[6]7.10,第二阶段提供一个模板代码,每个语言根据模板代码提供自己的代码文本输出。Franca 提供专用的语法解析库,第一阶段将Franca IDL转换成内存中的数据结构,第二阶段各语言的代码生成工具根据内存数据结构,输出语言特定的代码文本。还有一些特别的办法,使用 Java 或 C# 作为原生语言,将IDL规范定义作为原生语言的一个子集,这些原生语言有一个特点就是支持很好的反射能力,能在运行时获取被编译代码的详细类型信息。那么第一阶段就可以使用原生语言的编译器,第二阶段从编译结果中提取类型信息,根据类型信息生成目标代码。这些方式的IDL规范都接近一般的程序语言,方便人工阅读和编写。
也有的中间件使用XML 作为 IDL的表示方式,那么第一阶段就可以省略掉,因为XML标记直接就表达了接口语义。AutoSAR 使用的 ARXML 就是这种方式。但是人工阅读和编写就很不方便,需要工具支持。
总之,中间件的多语言支持需要慎重选择各语言Runtime的实现方式以及代码生成的实现方式。
软件架构方法论及 SOA 推导
前面讲了很多中间件产品中常用的关键技术。相对更大层面的软件架构来说,这些只是局部的技术点。用于某个行业领域的中间件产品往往会非常深度地决定这个行业领域应用软件所采用的软件架构。
软件系统规模比较小的时候,我们很少用架构这个词。所以早期有种说法:
这里的程序指的是解决特定的具体问题,一般不涉及大范围网络数据交换的独立软件。互联网让软件应用从小范围专业领域变成覆盖全球的信息系统,软件系统也从简单的程序演变出很多复杂的架构。
目前在汽车软件领域也正经历着类似的变化,由于智能网联与自动驾驶的需求,汽车软件的复杂度也以指数形式上升,同时由于以太网的引入,很多之前在互联网上可以使用的软件架构经过一些变换后也可以用到汽车软件上。典型的就是现在大家常说的SOA。
SOA是什么?更专业的说法,SOA是一种软件架构风格。车载中间件产品也会有其软件架构及架构风格,SOA 目前看来会是一个主流的趋势。
什么是软件架构,什么是架构风格,需要一个清晰的定义,这一章先从这里开始。
3.1 软件架构组成与架构风格
1、软件架构研究基础(Foundations for the Study of Software Architecture [24])
2、架构风格与基于网络的软件架构设计Architectural Styles and the Design of Network-based SoftwareArchitectures [23]
第一篇是1992年的论文,提出了软件架构的基础模型和架构风格的概念。第二篇的作者Roy Thomas Fielding 是 HTTP1.0/1.1 规范的主要制定者,这篇文章是他2000年的博士论文,在Web发展史上,这是一篇极其重要的经典文献,奠定了现代 Web 架构的基础。这都是20-30年前的文章,但是其对软件架构的阐述丝毫没有过时,一样在理论上指导着软件架构的设计。
很多汽车相关企业都在推进SOA化,但其架构风格背后的推理逻辑其实并不是显而易见的。只看到具体的技术点,而不知其由来,就很难准确理解并使用它。尤其是现代的汽车电子电器架构就是基于多种车载网络体系来构建,汽车软件已经成了典型的基于网络的分布式软件系统。原来基于网络的软件架构设计原理对汽车软件一样有非常重要的参考作用。
这一节尽量以易于理解的方式,用较短的篇幅将这两篇文章中关于软件架构和架构风格的阐述做一个综述。为后续的讨论做一个理论基础。
3.1.1 软件架构研究方法论
图3. 1以 UML 类图的表示了组成软件架构的基本概念。
表示“泛化(抽象)”概念,也就是逻辑上一般化与具体的关系,程序语言的继承。箭头所指父类,即比较“抽象”的概念,另一端是该概念的具体化呈现。ppPednc
表示“组成”关系,也就是整体与部分的关系。箭头所指为整体,另一端为组成整体的各个部分。ppPednc
表示遵循某个“规约”,程序语言中代表接口实现。箭头所指为具体的规约规则。ppPednc
软件架构由三个方面组成,架构元素,架构的组成形式,和一些形成架构的基本原则。架构元素有三种:
处理元素:执行实际的功能性运算与数据转换;是“计算和状态的所在地”;是在运行时执行某种功能的软件单元。
我们用四个不同的领域概念类比来理解这些架构元素的含义。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
视觉/Lidar/Radar感知算法,感知融合算法,车辆控制模型 |
|
|
架构的组成形式中包括“配置关系”与“约束属性”。“配置关系”是在系统的运行期间处理元素、连接元素和数据元素之间的关系结构。“约束属性”用于约束架构元素的选择。它于将架构元素约束到系统需求所需的程度。对应的实例如下表。
|
|
|
|
|
|
|
|
|
|
|
|
|
|
数据传输带宽,算法执行帧率,控制实时性要求,功能安全要求 |
架构的一个潜在但不可或缺的部分是在定义架构时做出的各种选择的一些基本原则。在软件架构中,基本原则解释了如何满足系统约束。这些约束是由从基本功能方面到各种非功能方面的考虑因素决定的,例如经济性、性能、和可靠性等。
根据我们需要构建的软件系统的约束需求,我们选择一组基本的原则,在这组原则的指导下,选择合适(约束符合)的架构元素(处理元素、数据元素连接元素)组成一个集合,并设计各种架构元素的关系结构。
不同的基本原则选择方式,会让软件架构呈现出不同的风格(Style),我们称之为架构风格。一种架构风格是一组协作的架构约束,这些约束限制了架构元素的角色和功能,以及架构元素之间的关系。
当我们谈及某种形式的软件架构时,实际上往往讨论的是架构风格,比如说 SOA。
每个架构设计决策可以被看作是对一种风格的应用,而一个软件架构往往会混用多种风格。
3.1.2 软件架构的评估方法
一种架构风格是一组协作的架构约束,但是经常会出现一种情况,一种约束的效果可能会抵消一些其它的约束所带来的好处。没有完美的设计,获得某种优势的同时,可能需要在另一方面付出代价。所以我们需要一种评估机制去从多个方面去评估一个软件架构的特性,以便我们在不同的可能性之间进行权衡。
性能
性能往往是软件架构首先要考虑的方面,软件架构需要满足应用的性能需求。
对于 I/O 性能,我们关注的一般是总吞吐量和平均的传输延迟。对于计算性能我们关注的是计算单元的总利用率以及计算任务的响应延迟。一个是衡量系统的总效率,一个是衡量系统的单次响应能力,这个会影响到用户可察觉的性能。
这两者有时是有冲突的。好的架构风格要能在满足响应性要求的情况下,尽可能支持系统能够达到的较高的总效率。
性能也受成本的约束,在移动平台或车载平台,性能还受功耗的约束。ppPednc
可伸缩性
可伸缩性要求架构能支持从小规模到大规模的平滑扩展。架构需要能够支持大量的组件以及这些组件之间交互的能力。可伸缩性能够通过以下方法来改善:ppPednc
-
简化组件ppPednc
-
将服务分布到很多组件(分散交互)ppPednc
-
风格可以通过确定应用状态的位置、分布的范围以及组件之间的耦合度,来影响这些因素。
简单性
如果分配给单独组件的功能足够简单,那么它们就更容易被理解和实现,也方便进行测试。越简单的组件也越能够被重复使用。架构要能够支持将复杂的功能分解为很多简单的组件,同时还要能够交互协同以完成预期的功能。就是要拆得开,还能合得起来。
可修改性
需求也会随时间发生变化,可修改性是对于应用的架构所作的修改的容易程度。可修改性能够被进一步分解为在下面所描述的可进化性、可扩展性、可定制性、可配置性和可重用性。
-
可进化性:
一个组件实现能够被改变而不会对其它组件产生负面影响的程度。
-
可扩展性:
将功能添加到一个系统中的能力。动态可扩展性意味着功能能够被添加到一个已部署的系统中,而不会影响到系统的其它部分。提高可扩展性的方法是在一个架构中减少组件之间的耦合,比如基于事件或消息进行交互。
-
可定制性:
指组件可以为一个客户进行定制化扩展,而不会对该组件的其它客户产生影响。支持可定制性的风格也可能会提高简单性和可扩展性,这是因为通过仅仅直接实现最常用的服务,允许客户端来定义不常用的服务,服务组件的尺寸和复杂性将会降低。
-
可配置性
指在部署后对于组件,或者对于组件配置的修改,这样组件能够使用新的服务或者新的数据元素类型。管道/过滤器风格和按需代码风格就是典型的例子。
-
可重用性
一个应用的架构中的处理元素、连接元素或数据元素能够在不做修改的情况下在其它应用中重用。在架构风格中提高可重用性的主要方法就是是降低组件之间的耦合(对于其它组件的标识的了解)和强制使用通用的组件接口。
可见性
指对组件之间的交互进行监视或仲裁的能力。可以通过以下方式提高可见性:交互的共享缓存、通过分层服务提供可伸缩性、通过反射式监视(reflective monitoring)提供可靠性、以及通过允许中间组件(例如,网络防火墙)对交互做检查提供安全性。风格能够通过限制必须使用通用性的接口,或者提供访问监视功能的方法,来影响基于网络的应用中交互的可见性。比如在自动驾驶应用中,我们强制每个算法组件报告它接收数据、处理数据、发送数据的帧率。
可移植性
如果软件能够在不同的环境下运行,软件就是可移植的。标准的通讯协议,标准化的API接口都可以提高软件的可移植性。SOME/IP 协议只管通讯的数据交换格式,可以兼容不同的通讯库的实现。AdaptiveAutoSAR 标准将应用程序可以使用的系统调用限制为 POSIX PSE51 标准(参见 4.3.1.1),方便移植到不同的 OS(Linux/QNX/VxWorks) ;同时提供标准的应用API接口,支持基于Adaptive AutoSAR的应用在不同的 Adaptive AutoSAR实现之间移植。
可靠性
从应用的架构角度来说,可靠性可以被看作当在处理元素、连接元素或数据之中出现部分故障时,一个架构容易受到系统层面故障影响的程度。架构风格能够通过以下方法提高可靠性:避免单点故障、增加冗余、允许监视、以及用可恢复的动作来缩小故障的范围。车载应用还有更高的功能安全要求。
3.2 常见基于网络应用的架构风格
图3. 2列举出了大多数基于网络的应用架构风格。有一些与网络应用相关性不大的其它架构风格没有放进来。
图中偏左侧黑色粗框标识的是几个基础风格,包括“客户-服务器,管道和过滤器、多副本,分层系统,虚拟机/解释器;基于事件的集成”等。其它风格是对这些基础风格的继承(或称为扩展),有的风格是继承自多个基础风格。
每个风格上部以淡粉色标注的标签表示正面的评估结果,如改进“网络性能、可伸缩性、可靠性等”,下部以淡青色标注的标签表示负面的评估结果。这里我们不会逐一介绍每个风格,而是在下文对SOA 风格的推导中叙述涉及到的风格。
3.3 面向服务(SOA)的架构风格推导
汽车软件最近开始了向 SOA 转型的趋势。从软件架构角度看,SOA是一组软件架构风格的统称。严格来说,SOA并不是一个单一的软件架构风格,而是一系列各具特点的软件架构风格的综合运用,其中每一种架构风格都推崇架构元素之间的一种特定的交互类型。
我们从一个空的架构风格开始,逐步增加新的约束,从而推导出 SOA 的架构风格,并结合车载软件和自动驾驶软件的特点来做进一步的说明。
3.3.1 “客户-服务器”风格
首先我们加入到我们约束集合中的是“客户-服务器”风格。客户-服务器约束背后的原则是关注点分离。“关注点分离”是软件设计思想中的一个关键概念,几乎可以用在软件设计从架构到具体实现的各个方面。这个概念比较抽象,简单理解,可以认为“一个软件单元(架构组件,软件模块,接口等),其关注的范围尽可能小,聚焦在某一个特定的领域范围(关注点)。”一个关注点可以看作是“功能,行为,数据”等,很难有一个通用准确的概括。不同的关注点由不同的软件单元来处理,软件的耦合程度就会降低,会带来架构和实现上的各种便利。
“客户-服务器”风格首先分离了“功能实现”与“用户接口”两个关注点。“功能实现”一般包括对数据的处理、计算、存储,“用户接口”是用户提供数据和获取结果的界面。这两者的分离可以让“功能实现”部分单独进化,而不影响用户的使用。在用户接口不变的情况下,“功能实现”可以采用更新的算法,更快的存储,更大的部署规模,或者移植到不同的技术平台,而这些对用户都是透明的。
对复杂的软件系统而言,把整个系统拆解成多个服务器程序,每个服务器程序关注特定的功能。这种拆分本身也是关注点分离思想的应用。
现代车载软件以及自动驾驶系统极为复杂,需要很多家不同供应商开发不同的软件组件。客户-服务器风格以服务界定功能边界,不同供应商开发按照预定义的接口实现特定的服务,为其它组件提供服务的同时也使用其它供应商开发的服务组件,只要接口定义好,多个不同的供应商的软件组件就可以协同工作。
3.3.2 状态分离与局部化
3.3.2.1程序“状态”的含义
“状态”这个词被用在很多地方,其含义往往有很多细微差别,容易被混淆。这里所谓的状态是指“某个软件组件内部包含的数据信息,这个数据信息会影响外部对这个软件组件发出请求的响应结果”。
我们给加法器A设置初始值0,然后每次加1 ,返回的结果是不一样的。
对加法器B而言,只要每次给出相同的输入数据,返回的结果是一样的,不依赖于加法器B的内部数据。
加法器A内部就保存了程序状态,假如多个客户端并发进行访问,取得的结果就会互相干扰。加法器B就是我们常说的无状态服务器,所有状态数据保存在客户端的请求中,多个客户端并发调用互不影响。这样就允许我们复制部署多个加法器B,分担承接大量并发请求。
图3. 5描述了多种软件架构风格在“状态分布”和“交互耦合程度”上的分布情况。这里我们先关注状态分布。图中纵轴的上部表示状态偏向在服务端保存,下端表示状态偏向在客户端保存。
状态偏向服务端的极端案例是“远程会话”风格,每个客户端在服务器上启动一个会话,然后调用服务器的一系列服务接口,最后退出会话。应用状态被完全保存在服务器上。如:FTP服务,Telnet服务等。
状态偏向客户端的极端案例是“客户-无状态-服务器”风格,从客户端发到服务器的每个请求必须包含用于理解请求所必需的全部信息,不能利用任何保存在服务器上的上下文(context),会话状态全部保存在客户端。
其它设计风格的“状态分布”模式处于两个极端中间。加入缓存机制的设计风格部分状态保存在客户与服务器之间的缓存机制上。分布式对象为基础的设计风格,状态主要保存在远程对象中,偏向服务器。“管道和过滤器”风格和“基于事件集成”风格没有明显的客户和服务器端,状态保存在各自组件中。
3.3.2.2 SOA服务的状态分布选择
前面说的是程序状态分布的通用概念。现在回到车载软件SOA风格。我们新增一条约束,“分离强状态服务与无状态服务,并控制状态在局部范围”。
1、一个是无状态服务与强状态服务要分离在不同的服务中
2、每一个服务要么是无状态,要么是强状态,避免中间路线。
无状态服务会显著改善服务的可见性、可靠性和可伸缩性。改善了可见性是因为监视系统仅仅只需要对单个请求进行分析就能得到其全部特质,不需要关心其它请求。改善了可靠性是因为它让从局部故障中恢复所需要做的工作减少了。改善了可伸缩性是因为服务器不必在多个请求之间保存状态,请求结束就可以迅速释放资源。服务器的实现得到简化,负载均衡也容易实现[23]5.1.3。
车载软件对于可见性和可靠性的要求是显而易见的。而对于可伸缩性的要求不高,因为车载软件高并发的场景并不多。但是只需要关注单个请求的实现,并迅速释放资源,依然会让服务器的实现简化很多,同时也会促进可靠性的提高。
对自动驾驶软件来说,单个请求的独立性,也意味着附着在请求上的功能性和非功能性约束也更为清晰明确。功能性约束体现在请求的参数和响应结果的数据形式上,因为一次服务只需要一次请求响应,约定好请求响应的数据规范就能界定服务的功能边界。非功能性约束往往体现在响应时间(或帧率),数据传递的 QoS 要求上,服务越简单,这些非功能性约束也就越容易明确。一方面可见性的提高能对这些非功能性约束做更好的监控,另一方面服务实现上满足这些非功能性约束也会越容易。比如,对响应时间的约束满足体现在任务调度机制上,无状态带来的简单话意味着实现良好任务调度机制就容易许多。
虽然无状态带来了诸多好处,但是在应用中状态依然是存在的。某些自动驾驶功能其状态往往需要用复杂的“有限状态机(FSM)”来定义。那如何来设计这些对状态强依赖的服务。解决的办法是:
1、在服务划分上分离无状态服务与有状态服务ppPednc
2、将状态限制在服务的局部范围,即少量特定的SOA服务
在服务划分上,我们应该尽量把能够进行的无状态化处理的服务识别出来,并按照无状态的方式去定义其服务接口并实现。而把涉及到复杂状态转换的部分集中在一个独立的服务中。不同的有状态服务之间,其各自涉及的状态范围应该是正交的,即不同服务的状态相互无关。各自服务将状态限制在自己服务本地,甚至还可以对外呈现出一定的无状态特征。
例如,对于一个 ACC 应用,涉及的服务可以简化的分解为如图3. 6所示的多个服务(只是简化表示)。我们可以把ACC状态机集中在一个ACC 会话服务中。它所依赖的其它服务是无状态的,只是根据输入产生对应的输出。
实际情况会更复杂一些,比如“前视算法服务”中并不是完全无状态,如果需要做多帧融合或者目标跟踪,其结果跟多帧的数据相关。这多帧的历史数据就是状态。解决的办法是进一步拆解成更小的粒度。目标跟踪算法做成单独的服务,输入是所有关联帧的数据。
SOA服务划分的无状态和有状态的分离,在形式上与函数式编程范式中的纯函数与副作用的分离相对应,只是描述的是不同粒度上的架构问题。所以也可以在前视算法服务内部再做更细粒度的状态分离。
也就是说,向“前视算法服务”这样的轻量级状态可以通过内部或外部的进一步分解来做到真正的无状态。服务划分得过于零碎,会导致服务部署配置的难度和额外的通讯开销,但这可以通过其它的技术优化手段来解决,后文会详述。
图中的“ACC会话服务”的状态就复杂的多。当用户启动ACC 功能,从功能激活到退出是一个完整的会话过程,会话的状态细节由状态机进行控制。这是典型的“远程会话”架构风格,这与一个Telnet 会话其实是非常相似的,都有一个会话生命周期过程。只不过在一辆车上,一个 ACC 会话同一时间只会出现一次,单辆车上不会出现同时多个ACC 会话实例。这个会话服务未必就没有办法是无法拆解成无状态的形式,但是会导致大量的状态数据在每次请求中传输,同时实现上没有状态机形式更自然,徒增复杂性。
设计某一个具体的车载SOA服务时,对服务状态分布的选择最好在强状态的“远程会话”和“无状态”两个极端风格中二选一。应该避免在客户端和服务端都维护状态数据。
从“关注点分离”的视角看,“无状态”化设计分离了状态“数据的存储与传输”和“状态数据的处理”两个关注点。
3.3.3 服务发现
复杂的软件系统被分解为大量小规模的服务后,服务之间也会有很多依赖关系,某个服务同时也会作为客户端访问其它服务。一个客户端访问另一个服务,需要知道该服务的访问点,对于TCP/IP 协议栈来说,至少包含IP和Port信息。同时,还需要知道该服务是否可用。因为服务可能还未启动,或者在启动中,或者因为某种原因停止了服务。
服务的访问点是可以通过配置文件静态配置的,如果系统中只有几个服务静态配置难度还不大,如果服务数量上升到几十个甚至更多,静态配置的维护难度就非常大。
某个服务启动时,为了它所依赖的服务已经就绪,就需要对服务的启动顺序进行管理。这对大量服务并存的系统也是很难做到的。
因此,我们给 SOA 架构风格增加一条约束“每个服务具备能被其它服务发现的能力,也能查找需要使用的其它服务”。
所谓实现被其它服务发现的能力,意味着该服务应该至少具备一下两个能力:
1、服务可用性状态发生变化时能通知其它服务ppPednc
第1条是事件发生时的主动通知,不关心谁接收。第2条是主动响应对本服务可用状态的查询。这也意味着每个服务需要维护自己的可用性状态。
图3. 8 SOA:客户-服务器-状态分布-服务发现
除了事件性的通知机制,“服务发现”也需要包括主动查询服务可用性的能力。上图显示了在 SOA架构风格上增加“服务发现”后的图示和约束。
相对于静态配置,“服务发现”实际上提供了动态配置的能力,提高了系统的可维护性和可配置性。因为服务不是静态配置的,当一个服务失效时,可以很快的用另一个相同功能的服务替换掉它,新服务的访问点信息会很快在系统中被其它服务获取,系统可以很快能从服务失效引起的错误中恢复,提高了系统的可靠性。当某个服务需要被升级时,也可以采用类似的方式进行,对系统的可进化性也有显著帮助。
3.3.4 基于“事件/消息”发布订阅
我们再增加一条约束,“服务之间支持基于‘事件/消息’的发布订阅机制,以降低服务之间的耦合性。”
关于这个约束有很多称呼方式,含义接近但又各有侧重点,如:事件总线(EventBus), 消息通讯,发布订阅模式等。
“事件总线”的称呼,关注点在于系统中事件的触发,比如UI程序中的用户交互,或者OS内核的中断。事件发生后“广播”出来,由感兴趣的软件模块去处理。事件产生源不关心事件的处理者是谁。但是对于本地程序来说事件的触发到事件的处理可能是在一个线程里同步执行的。
“消息通讯”关注点在于数据的传输方式以及隐含的消息的异步处理语意。意味着发送者发出“消息”后,就不再拥有消息数据的内存所有权(“泼出去的水”)。发送者和消息接收者对消息数据的处理是异步的,发送者不用等待接收者确认。
“事件总线”和“消息通讯”都可以实现“发布/订阅”模式。这里发布者和订阅者之间只共享“事件名称”,或称作“消息主题”。发布者按主题发布消息,不关心谁会收到;订阅者按主题接收消息,不关心消息从哪里来。
这种特性让软件模块的测试也变得非常方便,我们可以在非生产环境中发送模拟的消息来测试软件的功能。可以录制生产环境的消息然后线下回放来做仿真测试。
图3. 9 SOA:客户-服务器-服务发现-发布订阅
“发布/订阅”风格显著降低了系统各组件之间的耦合度。添加订阅某个主题消息件的新组件变得非常容易(可扩展性)、只要组件接收或发送消息格式(接口)确定,该组件就可以被用在任何支持这个消息格式的场合(可重用性);允许组件被替换而不会影响其它组件的接口(可进化性)。发布订阅风格为可扩展性、可重用性和可进化性提供了强有力的支持。
发布/订阅的一个缺点是:难以预料一个事件将会产生什么样的响应(缺乏可理解性),事件通知并不适合交换大粒度的数据,而且也不支持从局部故障中恢复。
上图为我们的 SOA 架构增加了基于“事件/消息”发布订阅风格。多个服务之间有相互交互的方式,交互方式有基于RPC的“请求/响应”,也有基于“事件/消息”的发布订阅方式。
从“关注点分离”的视角看,发布订阅分离了数据的“生产者”和“消费者”两个关注点。
3.3.5 服务代理
车载软件发展了几十年,有大量的稳定成熟的既有代码。车内广泛使用的网络总线也有Can、 Lin、 FlexRay 等很多种,连接在这些网络上的ECU 很难去支持服务发现、发布订阅等机制。对于这些成熟的既有系统,可以为它们增加一个代理服务。代理服务仍然按照原有的方式(如:Can 总线)跟既有系统进行通讯。但是代理服务对外可以以独立SOA服务的方式呈现,提供标准的访问接口, 接口暴露既有系统可以开放的部分能力。下图在SOA架构风格上增加了服务代理风格。
图3. 10客户-服务器-状态分布-服务发现-发布订阅-代理
服务代理还带来另一个好处。被代理的软件模块被隐藏在代理服务后面,可以单独进化。比如采用不同的技术路线网络总线重新实现。
但是服务代理作为额外的间接层会降低效率和用户可察觉的性能。所以服务代理暴露出哪些原有软件模块的功能需要仔细选择。比如,被代理的模块是一个实时性要求很高动力系统ECU,那就没必要把该ECU的高实时要求的控制信号暴露出来,只应该暴露出实时性要求不高的状态发布的等信息接口。
3.3.6 服务装配
在进行服务划分的时候,我们希望把每个服务设计得尽可能功能单一,这样服务简单,方便开发、测试和复用。但是会造成服务数量变大。
在服务可以执行之前,它必须被加载到应用程序(操作系统进程)的地址空间中。如果每个服务一个进程,如果有上百个服务,就会造成操作系统中运行着上百个进程,争抢系统资源。相当与把服务调度的工作交给了操作系统,让操作系统的进程调度代为执行服务的调度。我们知道,操作系统的进程切换是开销非常大的操作,也无法保证调度的精确性。比如,我们希望一个服务每秒钟执行30次(软实时),当有上百个繁忙的进程在系统中执行时,操作系统的进程调度策略是无法保证这个服务的调度要求的。
我们可以把多个相关的服务装配到同一个服务容器进程中,由服务容器来对这些服务进行调度。这样可以在用户空间而不是内核空间进行服务切换,避免了大部分进程切换的系统开销。同时可以自定义调度算法以满足服务的需要的调度要求。
如果服务装配策略(哪些服务装配到一个进程里)是在开发早期就做出了决策,这个时间开发人员往往并不知道服务搭配或部署的最佳方式,一旦决策有误,再变更难度就很大。此外,对“最佳”配置的定义,很可能会随着计算环境的变化而变化。
如果服务的实现与其初始配置紧密耦合,则修改服务可能会对其它服务产生不利影响,比如会导致其它服务需要被重新编译和部署。
解决问题较好的办法是动态服务装配机制。每个服务开发时并不是被预设为单独的进程,而是一个可以被动态加载的模块。(如:动态链接库Windows 上的DLL或Linux 上的 SO 文件)。在服务部署时才决定哪些服务被装配到同一个进程中。也可以在运行时才根据需要的加载服务,并在利用完成后卸载。甚至可以让服务在不同进程、不同操作系统中迁移(从一个进程中卸载,在另一个进程中装载)。
图3. 11客户-服务器-状态分布-服务发现-发布订阅-代理-装配
每个服务声明自己的调度要求(执行的频次,要求完成的时间等),由服务容器的调度算法来满足,不能满足时也能获知并收到告警。图3. 11将服务装配加入了我们的SOA架构风格。
“服务容器”本身也可以被设计成SOA服务,提供服务管理接口,用于加载、管理其它服务。所以图中“服务容器”继承自“服务器”,多个“服务器”又可以装配到“服务容器”。
从“关注点分离”的角度看,服务装配分离了“服务实现”与“服务部署”两个关注点。“服务实现”时优先关注功能的定义与实现,而部署决策可以被延迟指定。
服务装配还带来另一个优点,就是为服务之间的数据交互提供了优化的空间。虽然我们默认采用通过网络进行数据交换。但是当两个服务部署在一个进程内时,显然有更合适的数据交换通道。后文会进一步讨论这个问题。
3.3.7 服务监督
对系统中的服务进行监督管理是必要的。比如,Linux 系统的系统管理守护进程“Systemd”就是用于对Linux 系统服务进行监督管理。它会根据预定的配置在合适时间启动服务,并监督服务进程,如果进程消失,会自动重新启动。Systemctl 命令就是用来与 Systemd 服务进行交互的命令行接口。
对SOA服务而言,我们需要对服务的“生命周期”、服务的“健康状态”还有“服务质量”进行监督管理。
图3. 12 SOA:客户-服务器-状态分布-服务发现-发布订阅-代理-装配-监督