中
间件相关技术在计算机分布式系统中发展了很多年,尤其在互联网服务、大型商业系统中得到广泛使用。随着智能网联汽车的发展,现代汽车也逐步增加了以太网支持,这让之前的很多分布式系统技术也可以运用到汽车软件中,比如 SOA 软件架构。所以,基 于 SOA 的中间件也得到了越来越多的重视。
但是大家在讨论这些问题时,对很多概念表述其实很模糊。什么是中间件,不同语境下其含义差别很大。对于什么是 SOA,自动驾驶系统需要 SOA 吗,很多人也很困惑。本文结合中间件的发展历史、软件架构方法论,自动驾驶的特殊要求,做了一个综合性分析,给出这些问题的一家之言。
第一章对典型的中间件产品做了一些介绍和综述。并阐明了中间件产品的核心概念,简述中间件技术在互联网和车载系统两个领域的应用。
第二章对中间件涉及到的关键技术逐一进行说明,作为后续分析的知识基础。
第三章对软件架构的分析方法和软件架构风格做了通用性的论述,并以此方法论逐层递进推导 SOA 软件架构。
第四章在前文的基础上,进一步分析自动驾驶对 SOA 中间件的要求。并以 Adaptive AutoSAR 和 GENIVI 技术体系为基础,举例说明如何对其进行改进与扩充,以实现满足自动驾驶要求的中间件系统。
本文的读者定位为从事车载软件开发、自动驾驶系统开发的系统工程师,产品经理、软件架构师、算法工程师、软件开发工程师及测试人员。因为智能驾驶需要很多不同专业的人协同工作,并不是所有人都是软件或汽车软件背景。有些论述对计算机软件专业的朋友可能是基本常识,但对其它专业的朋友而言并不熟悉,为了能让各种不同背景的人都能一定程度上理解文章内容,本文尽量采用非常通俗的语言来描述,并配合各种图来进行阐述。本文避免使用有歧义的术语,所有术语在第一次出现时都给出其在本文的准确定义。
**
后台回复“
AES07
”,获取PDF完整版
中间件基础概念
1.1 典型的中间件产品的介绍
1.1.1 CORBA 及其衍生物
中间件这个词已经被使用了很多年,其含义范畴也在不停的演变。最早可以追溯到1991 年 CORBA 1.0 标准的诞生. CORBA 官方自述:"CORBA 是由对象管理组(OMG) 开发的标准,用于提供分布式对象之间的互操作性。CORBA 是世界领先的中间件解决方案,支持信息交换,独立于硬件平台、编程语言和操作系统。"
不过 CORBA 标准过于庞大复杂,很多公司都参与制定标准,为各自的利益加入很多复杂而不实用的特性,同时很多公司又独立各自另搞一套。所以实际上 CORBA 并没有大范围流行,尤其在互联网应用中实际很少有人采用。其开源版本 omniCORBA [3] 从1997 年发布第一版到 2020 年仍在持续更新,其网站主页一如 20 年前一般简洁朴素。
几个参与 CORBA 标准开发人员后来也嫌 CO
RBA 过于复杂,然后成立了一个公司开发了一个轻量级的支持“分布式对象”的系统[4],Zeroc ICE . 它
汲取了 CORBA 最核心的特性,做了更简洁高效的实现,并支持 10 多种语言的绑定。同时提供了一个中心化的发布订阅服务(Ice Storm), 支持网格计算等等。经过 20 多年的发展,已经非常成熟可靠。在军工、通信、游戏等领域有很多使用者,但知名度始终不是非常高,或许跟创始人的经营理念有关系,并没有做较大的推广。
CORBA ,ZeroC Ice 的应用场景都是开发跨网络的分布式应用。其作为中间件的核心作用主要有:
•
使用接口定义语言(IDL)进行规范化的通讯协议描述,让应用开发者关注通讯内容的业务语意,不需要去定义协议报文的细节。
•
以本地函数调用的方式对远程对象进行操作,中间件对应用层屏蔽具体通讯的细节。
它们都提供的接口定义语言(IDL)来描述通讯接口。提供工具根据 IDL 生成目标语言的代码骨架,与目标语言集成。这也是大多数通讯中间件的典型做法。
1.1.2 互联网时代的企业级中间件
互联网时代,很多人对中间件的认知是从 J2EE 体系的企业级中间件开始的
。
J2EE的核心理念是将企业应用的商务逻辑实现在一个个的 Enterprise Java Bean(EJB) 中。EJB 可以类比与 CORBA 中的远程对象,运行在由 J2EE 中间件平台提供容器中。容器由中间件供应商开发,提供了 EJB 运行所需要的环境。应用开发人员只需要开发与业务逻辑相关的 EJB 组件。
图
1. 1 J2EE
中间件
J2EE 定义了一系列标准,涉及到接口定义、名字服务、远程调用、数据库访问、事务处理等等。重量级的商业实现有 WebLogic 和 WebSphere, 轻量级开源的有 JBoss 和 Tomcat,这些都被称为 J2EE 中间件或 J2EE 应用服务器。然而在实际应用中,J2EE暴露出很多问题。一方面规范多而复杂,解决简单问题也需要先了解太多的技术内容,学习曲线陡峭;另一方面其运作体制是几个大厂商定期开会,定义、发布标准,各厂商实现应用服务器产品再发布更新,用户获取新版版本。这个周期对于快速迭代的互联网来说,实在太慢了。
J2EE 和 CORBA 有一个共同的问题,就是过于注重标准的完备性,而不关注开发的实用性。实际开发实践中,被广泛采用的是 Spring Framework。它不算完整的 J2EE 实现,但是也使用了很多 J2EE 规范,却更注重实用性,快速迭代解决现实问题。
1.1.3 轻量级的 RPC 框架
相对于重量级作为应用服务器存在的中间件,轻量级的 RPC(远程过程调用)框架被使用得更为广泛。这些 RPC 框架最基本的用途就是简化网络通讯程序的编写。
一般编写一个采用 TCP 或 UDP 的通讯程序,至少要自己定义通讯协议的报文格式;根据协议用代码进行报文内容的拼装与解析;如果直接使用原生的 Socket API ,没有经验的话很容易掉入各种陷阱;还要考虑异步 IO 机制以得到更好的性能。轻量级的 RPC 框架的目的就是帮助开发人员把这些与业务逻辑无关的通讯底层工作都做了,通讯协议使用IDL 定义,通讯相关代码都自动使用工具自动生成出来。开发人员集中精力处理与业务逻辑相关的数据处理。
Apache Thrift 是比较常用的一个 RPC 框架。它定位为一个“可扩展的跨语言服务实现”。其设计中也重点体现了功能扩展方便,增加语言支持也很方便。官方版本已经提供了 C++、Java、Python、PHP、Ruby、Erlang、Perl、Haskell、C#、 Cocoa、JavaScript、Node.js、Smalltalk、OCaml 和 Delphi 等语言的支持。其架构上也可以支持方便的替换不同的通讯通道(TCP/UDP 或共享内存)和数据序列化方式。而对线程调图 1. 1 J2EE 中间件度、异步操作等都只做了最精简的实现,这让它移植到一个新的语言也比较简单。其重点放在了跨语言的互操作性上。
相对于 Thrift,gRPC 更强调性能,对语言的支持略少一些,包括 Java、C#、Go、JavaScript、、Swift 和 NodeJS 等。其序列化协议就是 Protobuf,传输协议为HTTP/2,两者都是固定的,不能替换,这两者在性能上的优势也是 gRPC 高性能的原因之一。尤其是其序列化协议 Probobuf ,得到了广泛的应用。
与前文提到的 CORBA、ZeroC ICE、EJB 这些面向对象的中间件不同的是,Thrift 和gRPC 都是面向服务的。在分布式中间件设计中,“面向服务”实际上比“面向对象”更简单一些,但是在很多场合,反而更实用。关于这一点,在下文的 3.5.1 节有更详细的讨论。
1.1.4 消息中间件
基于消息的通讯中间件也在很多领域被广泛运用。RPC 机制的视角是:从“客户-服务”之间的需要一个通讯接口。基于消息的通讯其视角直接是数据(消息)本身,不关心谁是客户,谁是服务器。给数据一个主题(常称作 Topic),数据生产者给数据标记主题并发送出来,数据需求这根据主题索取数据,一般称作“发布/订阅模式”。
典型消息中间件协议及相关产品有 DDS 和 MQTT 两个体系。前者更强调高可靠性和实时性,尤其对数据通信的服务质量策略(QoS)有丰富的支持。后者强调低带宽占用,只需要极少的代码和有限的带宽就可以实现并工作,所以在物联网上得到广泛的运用。
RPC 和消息通讯各有优势,往往被结合起来使用,SOME/IP 协议对这两者都有支持,详见下文 2.4.5 与 2.4.6 节,甚至还可以互相实现,详见 3.5.2 节。
1.2 中间件的产品概念
通过上面的介绍,我们可以看出,中间件的概念其实并没有一个统一的定义。大家讨论中间件的时候,都用这个词,但可能讨论的并不是同一件事情。"中间件"这个词本身是一个就是一个相对概念。在分层设计是软件架构中的一种典型做法。任何一层相对其上下两层来说都是"中间层"。
通常意义上,我们讲的“中间件”是把特定应用开发所需要的一些共性技术或组件提取出来作为一个通用的产品,有这样的产品做基础,应用开发就会简单快速。
中间件产品的具体功能是与不同的行业应用领域相关的。但是不同的行业应用领域,其特定软件技术,或软件设计模式又是高度相通的。所以我们在说起“中间件”时,有时候指的是某个特定行业的功能需求,有时指的是一个软件设计模式,有时又是某一项具体技术,比如说通讯通道或者序列化技术等。其概念经常是随着上下文飘忽不定的。
为了后文的讨论更加精准,图 1.2 从“最小核心” “应用领域拓展”、“关键技术” 和“软件架构设计”几个角度对“中间件”这个概念做一个澄清。
“最小核心”和“应用领域拓展”在这一章阐述。“关键技术”和“软件架构设计”内容较多,专门作为一章来表述。
图中红色边框的部分,是本文讨论的内容范围,这些在自动驾驶相关的中间件技术中都会涉及到。
图
1. 2
中间件的两个应用领域
1.2.1 最小核心与两个应用领域
“中间件”往往是“分布式中间件”这个概念的简写,所以中间件通常有一个狭义的最小核心,即在分布式领域中负责解决通讯问题。这个最小核心里又涉及两种通讯方式,一种是远程过程调用(RPC),一种是消息传递。RPC 有明确的服务接口定义,主要用于1 对 1 的通讯,消息传递更关注数据的主题与结构,不一定需要明确的服务接口定义,可以进行多对多的通讯。
这两种方式并不是完全互斥的,实际上典型中间件产品或通讯协议两者都会兼收并蓄。比如 SOME/IP 协议的 Request/Response Communication 机制([7]4.2.2)相当于RPC,Event([7]4.2.4)相当于消息通讯。在实现上,我们也可以利用 RPC 机制去实现消息通讯,也可以利用消息通讯去实现 RPC 调用。“中间件”的最小核心关注的是数据通讯。
图中列出了两个应用领域路线:
1. 最小核心 → Web 应用框架 → 企业级应用中间件
2. 最小核心 → 车载中间件 → 自动驾驶中间件
这两个领域虽然在最小核心上是一样的,但是其领域拓展的技术方向差别很大。
1.2.3 Web 应用领域
第 1 条领域路线是从 Web 应用中间件到企业级应用中间件。要知道,最早计算机应用都是单机的,然后逐步变成分布式跨网络的。单机的计算机程序要运行,需要基本的资源,包括 CPU,内存,持久化存储(磁盘)。同样,Web 应用程序也需要相关的资源,下表是一个非严格意义上的对比,旨在显示二者的在抽象层面上的共性。
这个表格并不算完整,它只是说明与单机应用相比,Web 应用只是在各方面资源和技术的上采用了分布式技术而已。原来单机程序由操作系统提供的支持,改由各种分布式技术来提供。这些技术的不同实现的组合,形成了 Web 应用的中间件系统。既然是分布式的,其内核自然少不了 RPC 与消息系统。
在此基础上,继续叠加一下企业应用所需要的功能,如事务处理,工作流等等,企业应用往往有复杂的逻辑,有些被抽象成分布式对象来表达其数据和行为(面向对象思想在分布式系统上的延展),就又涉及对象的容器技术,对象的生命周期管理等等,这就形成了支持企业级应用的中间件系统。
1.2.3 车载应用领域
第 2 条领域路线是将中间件应用到车辆上,这时候关注的是实时性、可靠性,更多的总线类型支持、诊断的支持等等,这些对车载软件而言是基础功能,只要是装在车上的ECU 都需要。这些基础功能做好了,可以复用到所有车载 ECU 的开发上。因此叫车载中间件,也叫基础软件,典型的是 AutoSAR。
当车载中间件被用于自动驾驶系统时,又会有一些特别的要求。比如需要能满足更高的带宽需求以支持传感器数据传输,对任务执行的时间确定性要求,对异构平台的要求等等。对这些要求的满足构成适用于自动驾驶的中间件系统。
更进一步,为了能更快速的开发自动驾驶应用,如果把自动驾驶应用开发所需要的公共部分形成一个应用开发框架,让传感器适配、感知融合、规划决策、地图及定位、控制执行的适配等关键部分都有标准的开发模式并提供基础的实现,那么这个框架也可以叫自动驾驶的开发框架,是对车载中间件在自动驾驶领域的进一步扩展。
中间件的关键技术
有时候我们谈“中间件”实际是在指其中的某个关键技术或者是软件设计模式。与中间件相关的技术点非常多,这一章我们列举出其中的主要部分,梳理其相互关系,并对每个技术点及其相关产品做简要说明。
2.1 泛化的 RPC 概念模型
先来看最小核心中“远程过程调用(RPC)”范畴内最主要的几个概念。图 2. 1 以 UML类推描述了几个概念之间的依赖关系,虚线箭头 A -----> B ,表示 A 依赖于 B。
图
2. 1 RPC
相关概念依赖关系
接口定义语言(IDL)规范描述了通讯接口定义的数据结构,调用的输入参数与返回值等等。用户程序需要根据这个规范先编写“应用程序的接口定义”。一般中间件产品会支持多种开发语言,需要为每一种语言提供对应的“代码生成工具”,根据接口定义生成该语言的代码。生成的
代码典型地包含两部分,一部分是服务端的代
码桩(stub),代码桩定义了语言特定的软件接口,用户继承代码桩并提供具体的功能实现。另一部分为代理(Proxy)代码,服务的使用者通过代理代码访问远程的 RPC 实现。代理代码对用户屏蔽了实际的通讯细节。
实际的通讯细节由“中间件运行时(Runtime)”来执行。中间件运行时不仅仅要负责实际的通讯过程,还需要设计合适的任务调度机制,线程模型来保证 RPC 请求和处理的高效,给用户提供的 API 接口简洁易用。
“中间件运行时”需要采用合适的“数据序列化机制”来保证用户定义的复杂数据结构能够在“通讯通道”中传输,中间件运行时还需要能支持多种通讯通道来让中间件可以被应用到更广泛的场景。“通讯通道”往往需要符合特定的“通讯协议”规范。
2.2 接口定义语言
2.2.1 IDL 示例
我们先来看一个接口定义的描述
这是来自 GENIVI Common API 的一个例子。
这里使用的 IDL 语言规范是 GENIVI Franca[10][11]. 这个接口描述里定义了一个名为E03Methords 的 RPC 接口,定义了一个名为 “foo” 的方法,它包含有两个输入参数,分别为 Int32 和 String。“foo” 方法还有两个返回值。还有一个表示方法调用成功或失败的枚举类型。
接口描述里还包含了一个表示广播数据的 muStatus 类型,广播一个 Int32 类型的数据。
下面的代码示例是 gRPC 的接口定义,gRPC 采用 google protobuf 作为接口定义语言。示例中定义了一个名为 Greeter 的服务接口,包含一个SayHello 方法,指定了输入和输出的数据类型。
CORBA 遵循 OMG 组织的 IDL.
ROS 也有自己的消息格式规范.
Apache Thrift 的 IDL 规范.
Adaptive AutoSAR 的 IDL 是在 ARXML 格式规范里,这个格式不适合手动编写,一般使用特定的工具来编辑。
Web Service 也有自己 IDL 标准,称作 Web 服务描述语言(WSDL).[15]
图 2. 2 以 SysML 需求图的形式列举了典型的 IDL 所需要包含的能力。可以归结为“数据类型支持能力”和“接口描述能力”两部分。
图
2. 2 IDL
能力需求
2.2.2 数据类型支持能力
一般来说基本数据类型都会得到支持,即各种长度的整数、单/双精度浮点数、布尔值,字符串这些。枚举类型往往也会支持。
自定义数据结构的能力是 IDL 必须提供的,这是用户定义自己应用所需数据结构的基础。复杂数据类型支持一般包括“数据结构的嵌套”以及“集合类型”。有的会支持数据类型的继承。
2.2.3 接口描述能力
接口描述能力一般可以表达为类似一个函数的定义,包括函数名称,输入参数,输出参数。参数一般支持自定义数据类型和复杂数据类型,有的可以支持多态,但映射到目标开发语言时会有一定难度。
接口的方法一般默认是双向调用,意味着会有返回值。绑定到特定开发语言的时候,代码 API 如何获取返回值是一个需要精细设计的问题,涉及到同步调用和异步调用的问题,下文详述。
单向调用表示请求者只是发出对远程的操作,但是不关心返回值。这个能力有的 IDL规范会在 IDL 描述中指定,如 Franca:
fifireAndForget 关键字就是代表单向的含义。
有的产品不在 IDL 中指定单向还是双向,但是代码生成时两个调用方式都在支持,在使用时有用户自己选用,如 Zeroc ICE 的 oneway 代理。
在 IDL 中做单向声明会更准确的表明的服务接口的语意,便于代码生成和运行时做优化,也能在服务实现时根据这个语意做明确的处理。
QoS(Quality of Service,服务质量)是一个比较大的话题,基于 RPC 的中间件很少在 IDL 中描述 QoS 要求,往往在代码中体现。后文详述。
2.2.4 广播与
属性
发布订阅的设计模式能够有效的降低软件系统中各部分的耦合。基于消息中间件天生就是基于发布订阅的模式来设计,如各种 DDS 的实现。基于 RPC 的通讯中间件早期并没有明确的广播消息支持,但是也有其它方法实现发布订阅模式(后文详述).
AUTOSAR 服务模型将服务定义为提供的方法、事件和字段的集合[16]。这是要求通讯中间件明确支持事件的广播([14]3.4.2 节)。GENIVI Franca[10][11] IDL 通过 broadcast 关键字来支持。
服务或接口的 “字段” 也叫属性(Field, Attribute ) 可以读取、设置、和广播,后文详述。
2.2.5 高级特性
有些特定的 IDL 规范还会提供一些独特的功能。比如 gRPC 提供了流式的输入输出方式。
客户端可以连续发送多个请求,服务端可以连续对多个消息进行响应,这充分利用底层的数据通路,提高整体的响应速度和传输吞吐量。
Franca IDL 提供了对状态机的定义方式,称作协议状态机(PSM),如:
根据 IDL 生成的代码中也能根据定义的状态支持对应的转换机制和对服务请求的约束。很多自动驾驶功能有自己的状态机设置,很适合使用这种方式来描述。
2.3 通讯通道与通讯协议的可替换性
通讯通道和通讯协议是我们常说的名词,这两个概念相关性很强,有时候指的是同一件事情,有时候又有些差别。这里先做一些澄清。
网络协议一般都是分层结构,ISO/OSI 参考模型定义了七层,从下往上依次是物理层、链路层、网络层(IP 层)、传输层(TCP,UDP)、会话层、表示层和应用层。实际互联网使用协议栈并没有会话层和表示层,这两层的功能往往会由各自程序的应用层处理。
每一层的协议有不同的实现方式,这些不同的实现对于上层协议来说,就是不同的通讯通道。比如,网络层(IP 层),其底下的通讯通道可以是同轴电缆构成的以太网,也可以是 WiFi,甚至可以是 USB。每一种通道有其特定的物理层和链路层协议。
车载以太网常用的 SOME/IP 协议实际是应用层协议,它是基于 TCP 或 UDP 的,HTTP 协议也是应用层协议,它是基于 TCP 的。实际上 HTTP 协议在互联网领域已经变成了一个通用的通讯通道(或者通讯方式)的代称,还有很多协议是基于 HTTP 实现,比如Soap 协议,gRPC 的数据传输协议。
所以通讯通道和通讯协议是相对的,要根据上下文去判断。
车载的 SOA 应用的通讯目的是实现不同服务之间的数据交互,其底下的通讯通道可以是使用 SOME/IP 协议的以太网,也可以是共享内存,还可以是 DDS,不同通道有不同的协议实现方式。而 DDS 本身也可以基于以太网或共享内存。不同的设计方式各有优缺点,要根据实际需要选择。
IDL 只负责服务接口的定义,用户代码只有与生成的代码和 Runtime 接口进行交互,一般不用直接和通讯通道与通讯协议交互。这就为 Runtime 替换不同的通讯通道和通讯协议提供了可能性。
Adaptive AutoSAR 通讯管理规范就明确提出要求:通讯实现不绑定到特定的通讯协议,SOME/IP 协议是必须支持的,但要求能替换成其它协议([16]P10)。实际上各家Adaptive AutoSAR 厂家的产品,往往会在 SOME/IP 协议外,支持 DDS 或共享内存的通讯方式。如华为的 MDC 平台使用了自研的 Adaptive AutoSAR,就对 SOME/IP、DDS 和共享内存都能够支持。
图 2. 3 是 Apache Thrift 的概念图,下面两层中,Protocol 负责序列化,Transport 层负责实际的数据传输通道,默认一般会支持 TCP 传输,TCP 数据的协议格式是 Thrift 私有格式,Thrift 文档中并不强调这个协议格式,各语言的运行时采用一样的协议格式就可以互相通讯。用户可以做自己的 Transport 层实现,比如实现共享内存的通讯。
图
2. 3 Thrift
序列化协议与传输通道
Genivi Common API 也可以绑定不同的通讯通道和协议,目前已经支持的有 d-bus 和 SOME/IP。与 Thrift需要用户在代码上指定使用哪种通道不同,Common API 用户
图2. 4 GENIVI 多协议绑定
编写代码时不需关注底下绑定的是那种通讯通道和协议,应用部署时可以通过配置文件指定,程序运行时根据配置文件动态加载指定的通讯通道。
图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 是什么,不是什么,或者说它定义了什么,没有定义什么
-
SOME/IP 与以太网的关系
-
SOME/IP 如何支持SOA服务进行通讯
2.4.1 为什么需要SOME/IP协议
汽车内部通讯最常用的就是 Can总线,但是Can总线的局限性也很明显:
-
速度低,普通Can <500kbps, 高速Can 1Mbps, Can FD 5Mbps
-
有效数据载荷之外的额外开销大,甚至超过50%
-
报文有效数据长度太小,普通Can 8字节,Can FD 64 字节
Can FD 是在能与传统Can协议兼容前提下做的扩充,但架不住汽车上功能越来越多越来越复杂,尤其是智能座舱,智能驾驶,OTA 等相关功能的引入,需要更快速、支持大量数据传递、更能适应复杂汽车软件架构的通讯协议。针对上面 Can 总线的局限,SOME/IP 协议提供了至少以下几方面的能力提高。
-
速度提高:基于以太网,典型车载以太网有100Mbps,1000Mbps的以太网也很常见,尤其是在智能座舱和智能驾驶的域控制器中,千兆以太网是标配。将来随着光纤以太网的应用,速度达到1G~10Gbps 也不会太远。
-
报文长度扩大:基于UDP协议
时
,SOME/IP 报文的长度最大可以有1400字节,超过1400字节,可以使用TCP协议。Classic AutoSAR 有一个 SOME/IP TransportProtocol
[18]
,这个协议支持对超过1400字节的报文进行分隔传输。
-
SOME/IP 协议的设计上引入了面向服务的概念,有利于各种车载应用的模块化设计和互操作。
2.4.2 SOME/IP是什么
SOME/IP 核心是两个协议,一个用于服务之间进行数据交互(称作SOME/IP协议[7]),一个用于服务发现(称作SOME/IP-SD协议)。协议内容包括:
这两个协议是定义在 AutoSAR Foundation 部分。
图2. 5 SOME/IP 与 AutoSar
这意味着,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与以太网
UDP 还是 TCP
既然SOME/IP 底下的通讯协议可以是UDP或 TCP,那么什么时候该用什么底层协议,有什么差别?我们先看看 UDP和 TCP 的区别
|
UDP
|
TCP
|
有效载荷数据大小
|
1472
|
流式传输,无限制
|
连接建立时间
|
无
|
三次握手,时间长
|
保证到达
|
不保证
|
保证,失败会重传
|
接收顺序保证
|
不保证
|
保证
|
流量控制
|
不控制,收不过来就扔掉
|
慢启动,拥塞控制
|
广播支持
|
支持(广播或多播)
|
不支持,面向连接,只能一对一
|
可以看到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:
-
不用建立连接,直接发送数据
-
发送速率没有约束,但是接收方收不到会丢弃,需要发送方选择一个合适的发送速率
-
不保证顺序,那就让SOME/IP 报文不超过一个UDP报文的有效载荷大小,就不用把SOME/IP数据在UDP层拆成多个包。
-
UDP不保证可靠到达,那就要在SOME/IP 的协议实现层来做错误处理,比如重新发送SOME/IP请求
-
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 提供了更优化的拥塞控制策略,提高了传输的效率。
图2. 6各协议的关系
但是 SCTP 仍然有较繁琐的连接建立过程(改善了安全性)。这方面,QUIC协议有更好的表现。
QUIC全称Quick UDPInternet Connection。是基于UDP实现的可靠数据传输协议。QUIC 协议的主要目的,是为了整合 TCP 协议的可靠性和 UDP 协议的速度和效率。相对TCP它有如下重点优化:
-
简化了连接的建立过程,加快了连接的速度
-
支持一个连接中多个传输流
-
多种手段改进了拥塞控制算法,
这些特性让 QUIC 成为 TCP 的最好替代者。工程实践中可以考虑采用。HTTP3.0协议就是要求底下采用QUIC协议。
但是在需要广播或多播的场合,还是只能使用 UDP。
其实SOME/IP 协议的下层通道甚至可以不使用以太网,比如采用SPI。车载域控制器中往往为了实现功能安全会在SoC之外配一个满足ASIL-D规范的的MCU,如图2.7:
图2. 7使用 SPI 做备份通道
SoC 与 MCU 之间除了以太网连接外,还有 SPI 作为冗余备份。这种情况下,可以在SPI驱动中实现对 SOME/IP报文的传输。应用程序只需要使用 SOME/IP 协议,而不关心底下实际的数据通道。
2.4.5 SOME/IP 与RPC
我们结合 SOME/IP 的协议来看它对 RPC的支持。报文格式如下:
图2. 8 SOME/IP 报文头部格式
其中32位的 Message ID 由两部分组成,一个是Service ID,一个是 Method ID。假如以Franca规范定义的一个服务 HelloWorld的定义如下,其中包含了一个方法sayHello,
interface HelloWorld {
version {major 0 minor 1 }
method sayHello {
in {
String name
}
out {
String message
}
}
}
当这个服务接口与SOME/IP进行绑定时,就需要指定其Service ID 和 Method ID(此时,报文中的Message Type 为 0x00或 0x01)。如下:
define org.genivi.commonapi.someip.deployment for interfacecommonapi.examples.HelloWorld {
SomeIp
ServiceID
= 4660
methodsayHello {
SomeIp
MethodID
= 30000
SomeIpReliable = true
in {
name {
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,
interface MyService{
version {major 1 minor 2 }
broadcastmyStatus {
out {
Int32 myCurrentValue
}
}
}
绑定到SOME/IP时需要为其指定 EvernID。
define org.genivi.commonapi.someip.deployment forinterface commonapi.examples. MyService {
SomeIpServiceID = 4660
broadcastmyStatus {
SomeIp
EventID
= 33010
SomeIpEventGroups = { 33010 }
out {
}
}
}
EventGroup 的定义会被服务发现报文使用,这里不作详述。
当以UDP报文发送事件消息时,可以使用UDP的多播机制,同一个多播组的侦听者都能收到消息,这是SOME/IP消息通讯的基础实现形式。
基于DDS和MQTT的消息中间件实现会比这个更复杂一些,基于UDP的多播只是它们实现组内广播的一种物理形式,在不同的网络环境中有不同的实现方式,也可以在某个局部网络采用存储转发的方式,还有更多的QoS支持。
但SOME/IP的消息通讯仅仅定义了一个广播报文形式与消息发现机制,协议本身除了UDP多播外不涉及其它多播实现方式,也不涉及QoS,具体的实现可以进一步扩充这些特性的支持。
2.5 共享内存及零拷贝
2.5.1 共享内存加速的必要性
中间件技术带来了软件架构上的便利,让用户可以把复杂的功能拆解成多个不同的服务,协同工作。但是也带来另外的问题,就是通讯的延迟。下表展示了计算机系统中不同形式的数据传输所需要的时间。可以看到,一次主内存访问的时间约100纳秒,但是如果数据在二级缓存中,访问速度就提高了一个数量级。从主内存读取1MB数据,约60000纳秒,但是通过千兆以太网传输,速度就下降了至少两个数量级。
也可以用更直观的方式来看,如自动驾驶典型使用的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。
图2. 9虚拟内存映射
为了实现进程间通过内存的数据共享,操作系统至少要提供两类系统调用。一个是将本进程的虚拟地址映射到物理内存区(创建共享内存),一个是提供进程间的同步机制。
同步机制是必须的,共享内存区对于进程A和进程B是一块竞争资源。如果同时进行读写访问会产生不可预料的错误。进程A写入数据完成后,需要有机制通知进程B可以读取。进程B读取完数据后,也需要告诉进程A可以写入新的数据。
同步机制可以使用操作系统提供的互斥锁、信号量等同步原语。所以,可以认为,冰羚提供的最基本的功能就是
对操作系统共享内存操作函数和同步机制的封装
。可以使用简便的API(C或 C++)进行上述操作,而不用理解操作系统同步原语的细节。
多帧数据缓存、流控、发布订阅
一般来说,共享内存最简单的使用方式就是两个进程映射同一块内存区到自己的虚拟地址空间后,进程A写入数据,完成后通过同步机制唤醒进程B读取数据,进程B使用完数据后,通知进程A写入新数据。可以认为这是一个单帧的数据交换。
然而实际应用场景远比这复杂。试想一个低速场景下的环视自动泊车应用。四个环视鱼眼摄像头采集视频数据,同时有4个模块需要以不同的方式使用这些摄像头的数据:
-
“环视拼接算法”需要将4个摄像头的画面进行畸变矫正后拼接成一个顶视图画面,为了人观看流畅,需要至少30fps 帧率
-
“障碍物检测算法”需要检测画面中的障碍物,因为是低速场景,要求15fps的帧率
-
“停车位检测算法”需要检测画面中的划线停车位,需要10fps 的帧率
-
“行车记录仪”将画面编码保存为视频数据,需要20fps 的帧率。
图2. 10环视泊车应用共享内存示例
这里我们可以看到有几个难点:
-
产生的数据是连续的多帧
-
有多个消费者
-
多个消费者的帧率不同
一个能够容纳多帧数据的缓冲区以及对应的缓冲区管理机制是必须,数据在一个帧缓冲区填写好后,不用等消费者用完,就可以在另一个缓冲区中写入新数据,这可以大大提高系统的数据吞吐量。但是到底保留多少个缓冲区?数据消费者读取太慢,所有缓冲区都满了,那么是放弃新数据,还是阻塞写入者,让数据生产得慢一些?这就涉及到缓冲区管理和流量控制的机制了。这也是我们使用共享内存的时候常遇到的问题。冰羚内置了多帧缓冲区管理和流量控制的能力。
当有一个帧内存被多个消费者使用时,冰羚内部实现会对每一个帧内存维护一个计数器,表示当前的消费者数量。消费者释放这一块内存区时,计数器减1。计数器为0时,该内存区可以用来写新数据。再上面的例子中,极端情况下,三个较慢帧率的数据消费者进程(行程记录编码,停车位检测算法,障碍物检测算法)各锁定了一个帧内存区没有释放,但只要还有两个帧内存区,一样能保证拼接算法以更高的帧率获取数据而不会被其它进程阻塞住。当然,更多的帧容量能防止消费者计算时间的抖动,效果更好。
前面的例子中,一个数据生产者,有多个数据的消费者,典型的实现机制是使用操作系统提供的信号量同步机制,让多个消费者等待新数据的到达。冰羚提供了一套设计精巧的发布订阅机制API,封装了底层的进程间数据同步机制。
零拷贝
前面说到,200万像素摄像头每帧数据4MB,如果每秒30帧,就有120MB的数据。如果多个消费者都需要拷贝数据到自己的缓冲区,那么每秒钟会有几百MB的数据在传输,大量消耗内存带宽和CPU资源,也消耗了额外的内存空间。
最好的解决办法就是在这块共享内存区中,数据生产者写入,数据消费者读取,数据就在原地,不需要做额外的复制。这就是所谓的零拷贝。
道理很简单,但是真正实现起来还是有很多细节技术。一方面是消费者和生产者之间的同步机制,前面已经讲过。其实多帧缓存也跟为了达到零拷贝的目的相关。不同消费者可以同时锁住一个数据帧的内存,同时使用数据进行处理,因为它们各自的处理速度不一样(帧率不同),一个消费者已经完成处理并释放数据帧内存区,而另一个仍然占用。所以需要在另外的帧数据区中读写新的数据。
另外,API形式上也有一些技巧。对与C语言,比较简单,一般是直接将一个共享内存地址转换成C Struct 的指针再对这个 Struct 进行读写。C++ 稍微复杂一些,我们需要把 C++ 的对象放在共享内存区。C++的 new 操作符实际有两步动作:
1.分配内存
2.调用构造函数
默认的内存分配是在堆中进行,我们可以单独重载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中间件与异步I/O
图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 用于为不同类型业务提供区别性的服务策略,给那些对带宽、时延、时延抖动、丢包率等敏感的业务提供更加优先的服务等级,使业务能满足用户正常、高性能使用的需求。
下面是一些典型的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 类图的表示了组成软件架构的基本概念。
注: 简单的 UML 符号语意。
表示“泛化(抽象)”概念,也就是逻辑上一般化与具体的关系,程序语言的继承。箭头所指父类,即比较“抽象”的概念,另一端是该概念的具体化呈现。
表示“组成”关系,也就是整体与部分的关系。箭头所指为整体,另一端为组成整体的各个部分。
表示遵循某个“规约”,程序语言中代表接口实现。箭头所指为具体的规约规则。
图3. 1软件架构组成
软件架构由三个方面组成,
架构元素
,架构的
组成形式
,和一些形成架构的
基本原则
。架构元素有三种:
处理元素
:执行实际的功能性运算与数据转换;是“计算和状态的所在地”;是在运行时执行某种功能的软件单元。
数据元素
:承载着被使用和转换的信息。
连接元素
:将架构的不同部分结合在一起的粘合剂。
我们用四个不同的领域概念类比来理解这些架构元素的含义。
域
|
处理元素
|
数据元素
|
连接元素
|
计算机硬件架构
|
CPU, Memory
|
指令、数据
|
总线系统
|
计算机 OS
|
进程、线程
|
堆、栈,函数参数
|
IPC机制,系统调用
|
汽车 EE 架构
|
各种 ECU
|
消息,总线信号
|
Can, Lin,ETH 总线
|
自动驾驶软件
|
视觉/Lidar/Radar感知算法,感知融合算法,车辆控制模型
|
摄像头图像数据,点云数据
|
RPC ,消息发布与订阅
|
架构的组成形式中包括“配置关系”与“约束属性”。“配置关系”是在系统的运行期间处理元素、连接元素和数据元素之间的关系结构。“约束属性”用于约束架构元素的选择。它于将架构元素约束到系统需求所需的程度。对应的实例如下表。
领域
|
配置关系
|
约束属性
|
计算机硬件架构
|
总线拓扑,对称多处理(SMP),MPP
|
芯片主频、总线带宽,功耗
|
计算机 OS
|
内核组成模式,进程调度策略
|
地址空间大小,线程栈空间,I/O速率
|
汽车 EE 架构
|
EE 架构拓扑
|
总线带宽,实时性要求,功能安全要求
|
自动驾驶软件
|
算法流程的前后依赖关系。
各模块的部署模式
|
数据传输带宽,算法执行帧率,控制实时性要求,功能安全要求
|
架构的一个潜在但不可或缺的部分是在定义架构时做出的各种选择的一些
基本原则
。在软件架构中,基本原则解释了如何满足系统约束。这些约束是由从基本功能方面到各种非功能方面的考虑因素决定的,例如经济性、性能、和可靠性等。
结合上面所述,我们可以把设计一个软件架构描述为:
根据我们需要构建的软件系统的约束需求,我们选择一组基本的原则,在这组原则的指导下,选择合适(约束符合)的架构元素(处理元素、数据元素连接元素)组成一个集合,并设计各种架构元素的关系结构。
不同的基本原则选择方式,会让软件架构呈现出不同的风格(Style),我们称之为架构风格。一种架构风格是一组协作的架构约束,这些约束限制了架构元素的角色和功能,以及架构元素之间的关系。
当我们谈及某种形式的软件架构时,实际上往往讨论的是架构风格,比如说 SOA。
每个架构设计决策可以被看作是对一种风格的应用,而一个软件架构往往会混用多种风格。
3.1.2 软件架构的评估方法
一种架构风格是一组协作的架构约束,但是经常会出现一种情况,一种约束的效果可能会抵消一些其它的约束所带来的好处。没有完美的设计,获得某种优势的同时,可能需要在另一方面付出代价。所以我们需要一种评估机制去从多个方面去评估一个软件架构的特性,以便我们在不同的可能性之间进行权衡。
性能
性能往往是软件架构首先要考虑的方面,软件架构需要满足应用的性能需求。
对于 I/O 性能,我们关注的一般是总吞吐量和平均的传输延迟。对于计算性能我们关注的是计算单元的总利用率以及计算任务的响应延迟。一个是衡量系统的总效率,一个是衡量系统的单次响应能力,这个会影响到用户可察觉的性能。
这两者有时是有冲突的。好的架构风格要能在满足响应性要求的情况下,尽可能支持系统能够达到的较高的总效率。
|
I/O (存储或网络)
|
计算 (CPU/GPU/NPU)
|
总效率
|
总吞吐量
|
总利用率
|
响应性
|
平均传输延迟
|
实时性
|
性能也受成本的约束,在移动平台或车载平台,性能还受功耗的约束。
可伸缩性
可伸缩性要求架构能支持从小规模到大规模的平滑扩展。架构需要能够支持大量的组件以及这些组件之间交互的能力。可伸缩性能够通过以下方法来改善:
-
简化组件
-
将服务分布到很多组件(分散交互)
-
根据监视的结果对组件之间的交互进行动态控制
风格可以通过确定应用状态的位置、分布的范围以及组件之间的耦合度,来影响这些因素。
简单性
如果分配给单独组件的功能足够简单,那么它们就更容易被理解和实现,也方便进行测试。越简单的组件也越能够被重复使用。架构要能够支持将复杂的功能分解为很多简单的组件,同时还要能够交互协同以完成预期的功能。就是要拆得开,还能合得起来。
可修改性
需求也会随时间发生变化,可修改性是对于应用的架构所作的修改的容易程度。可修改性能够被进一步分解为在下面所描述的可进化性、可扩展性、可定制性、可配置性和可重用性。
-
可进化性
:
一个组件实现能够被改变而不会对其它组件产生负面影响的程度。
-
可扩展性
:
将功能添加到一个系统中的能力。动态可扩展性意味着功能能够被添加到一个已部署的系统中,而不会影响到系统的其它部分。提高可扩展性的方法是在一个架构中减少组件之间的耦合,比如基于事件或消息进行交互。
-
可定制性
:
指组件可以为一个客户进行定制化扩展,而不会对该组件的其它客户产生影响。支持可定制性的风格也可能会提高简单性和可扩展性,这是因为通过仅仅直接实现最常用的服务,允许客户端来定义不常用的服务,服务组件的尺寸和复杂性将会降低。
-
可配置性
指在部署后对于组件,或者对于组件配置的修改,这样组件能够使用新的服务或者新的数据元素类型。管道/过滤器风格和按需代码风格就是典型的例子。
-
可重用性
一个应用的架构中的处理元素、连接元素或数据元素能够在不做修改的情况下在其它应用中重用。在架构风格中提高可重用性的主要方法就是是降低组件之间的耦合(对于其它组件的标识的了解)和强制使用通用的组件接口。
可见性
指对组件之间的交互进行监视或仲裁的能力。可以通过以下方式提高可见性:交互的共享缓存、通过分层服务提供可伸缩性、通过反射式监视(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列举出了大多数基于网络的应用架构风格。有一些与网络应用相关性不大的其它架构风格没有放进来。
图3. 2常见的软件架构风格之间的关系
图中偏左侧
黑色粗框
标识的是几个基础风格,包括“客户-服务器,管道和过滤器、多副本,分层系统,虚拟机/解释器;基于事件的集成”等。其它风格是对这些基础风格的继承(或称为扩展),有的风格是继承自多个基础风格。
每个风格上部以淡粉色标注的标签表示
正面
的评估结果,如改进“网络性能、可伸缩性、可靠性等”,下部以淡青色标注的标签表示
负面
的评估结果。这里我们不会逐一介绍每个风格,而是在下文对SOA 风格的推导中叙述涉及到的风格。
3.3 面向服务(SOA)的架构风格推导
汽车软件最近开始了向 SOA 转型的趋势。从软件架构角度看,SOA是一组软件架构风格的统称。严格来说,SOA并不是一个单一的软件架构风格,而是一系列各具特点的软件架构风格的综合运用,其中每一种架构风格都推崇架构元素之间的一种特定的交互类型。
我们从一个空的架构风格开始,逐步增加新的约束,从而推导出 SOA 的架构风格,并结合车载软件和自动驾驶软件的特点来做进一步的说明。
图3. 3空风格
3.3.1 “客户-服务器”风格
首先我们加入到我们约束集合中的是“
客户-服务器
”风格。客户-服务器约束背后的原则是关注点分离。“关注点分离”是软件设计思想中的一个关键概念,几乎可以用在软件设计从架构到具体实现的各个方面。这个概念比较抽象,简单理解,可以认为“一个软件单元(架构组件,软件模块,接口等),其关注的范围尽可能小,聚焦在某一个特定的领域范围(关注点)。”一个关注点可以看作是“功能,行为,数据”等,很难有一个通用准确的概括。不同的关注点由不同的软件单元来处理,软件的耦合程度就会降低,会带来架构和实现上的各种便利。
“客户-服务器”风格首先分离了“功能实现”与“用户接口”两个关注点。“功能实现”一般包括对数据的处理、计算、存储,“用户接口”是用户提供数据和获取结果的界面。这两者的分离可以让“功能实现”部分单独进化,而不影响用户的使用。在用户接口不变的情况下,“功能实现”可以采用更新的算法,更快的存储,更大的部署规模,或者移植到不同的技术平台,而这些对用户都是透明的。
对复杂的软件系统而言,把整个系统拆解成多个服务器程序,每个服务器程序关注特定的功能。这种拆分本身也是关注点分离思想的应用。
现代车载软件以及自动驾驶系统极为复杂,需要很多家不同供应商开发不同的软件组件。客户-服务器风格以服务界定功能边界,不同供应商开发按照预定义的接口实现特定的服务,为其它组件提供服务的同时也使用其它供应商开发的服务组件,只要接口定义好,多个不同的供应商的软件组件就可以协同工作。
图3. 4 SOA:客户-服务器
3.3.2 状态分离与局部化
3.3.2.1程序“状态”的含义
“状态”这个词被用在很多地方,其含义往往有很多细微差别,容易被混淆。这里所谓的状态
是指“某个软件组件内部包含的数据信息,这个数据信息会影响外部对这个软件组件发出请求的响应结果”。
假设有一个加法器A,提供了两个接口:
1、设置初始值
2、增加 n 并返回结果
我们给加法器A设置初始值0,然后每次加1 ,返回的结果是不一样的。
对另一加法器 B,提供如下接口:
输入两个操作数,返回其加法结果
对加法器B而言,只要每次给出相同的输入数据,返回的结果是一样的,不依赖于加法器B的内部数据。
加法器A内部就保存了程序状态,假如多个客户端并发进行访问,取得的结果就会互相干扰。加法器B就是我们常说的无状态服务器,所有状态数据保存在客户端的请求中,多个客户端并发调用互不影响。这样就允许我们复制部署多个加法器B,分担承接大量并发请求。
图3. 5描述了多种软件架构风格在“状态分布”和“交互耦合程度”上的分布情况。这里我们先关注状态分布。图中纵轴的上部表示状态偏向在服务端保存,下端表示状态偏向在客户端保存。
图3. 5不同架构风格的状态与交互耦合程度的分布
状态偏向服务端的极端案例是“远程会话”风格,每个客户端在服务器上启动一个会话,然后调用服务器的一系列服务接口,最后退出会话。应用状态被完全保存在服务器上。如:FTP服务,Telnet服务等。
状态偏向客户端的极端案例是“客户-无状态-服务器”风格,从客户端发到服务器的每个请求必须包含用于理解请求所必需的全部信息,不能利用任何保存在服务器上的上下文(context),会话状态全部保存在客户端。
其它设计风格的“状态分布”模式处于两个极端中间。加入缓存机制的设计风格部分状态保存在客户与服务器之间的缓存机制上。分布式对象为基础的设计风格,状态主要保存在远程对象中,偏向服务器。“管道和过滤器”风格和“基于事件集成”风格没有明显的客户和服务器端,状态保存在各自组件中。
3.3.2.2 SOA服务的状态分布选择
前面说的是程序状态分布的通用概念。现在回到车载软件SOA风格。我们新增一条约束,“
分离强状态服务与无状态服务,并控制状态在局部范围
”。
这个约束的核心含义有两点:
1、一个是无状态服务与强状态服务要分离在不同的服务中
2、每一个服务要么是无状态,要么是强状态,避免中间路线。
无状态服务会显著改善服务的可见性、可靠性和可伸缩性。改善了可见性是因为监视系统仅仅只需要对单个请求进行分析就能得到其全部特质,不需要关心其它请求。改善了可靠性是因为它让从局部故障中恢复所需要做的工作减少了。改善了可伸缩性是因为服务器不必在多个请求之间保存状态,请求结束就可以迅速释放资源。服务器的实现得到简化,负载均衡也容易实现
[23]
5.1.3。
车载软件对于可见性和可靠性的要求是显而易见的。而对于可伸缩性的要求不高,因为车载软件高并发的场景并不多。但是只需要关注单个请求的实现,并迅速释放资源,依然会让服务器的实现简化很多,同时也会促进可靠性的提高。
对自动驾驶软件来说,单个请求的独立性,也意味着附着在请求上的功能性和非功能性约束也更为清晰明确。功能性约束体现在请求的参数和响应结果的数据形式上,因为一次服务只需要一次请求响应,约定好请求响应的数据规范就能界定服务的功能边界。非功能性约束往往体现在响应时间(或帧率),数据传递的 QoS 要求上,服务越简单,这些非功能性约束也就越容易明确。一方面可见性的提高能对这些非功能性约束做更好的监控,另一方面服务实现上满足这些非功能性约束也会越容易。比如,对响应时间的约束满足体现在任务调度机制上,无状态带来的简单话意味着实现良好任务调度机制就容易许多。
虽然无状态带来了诸多好处,但是在应用中状态依然是存在的。某些自动驾驶功能其状态往往需要用复杂的“有限状态机(FSM)”来定义。那如何来设计这些对状态强依赖的服务。解决的办法是:
1、在服务划分上分离无状态服务与有状态服务
2、将状态限制在服务的局部范围,即少量特定的SOA服务
在服务划分上,我们应该尽量把能够进行的无状态化处理的服务识别出来,并按照无状态的方式去定义其服务接口并实现。而把涉及到复杂状态转换的部分集中在一个独立的服务中。不同的有状态服务之间,其各自涉及的状态范围应该是正交的,即不同服务的状态相互无关。各自服务将状态限制在自己服务本地,甚至还可以对外呈现出一定的无状态特征。
例如,对于一个 ACC 应用,涉及的服务可以简化的分解为如图3. 6所示的多个服务(只是简化表示)。我们可以把ACC状态机集中在一个ACC 会话服务中。它所依赖的其它服务是无状态的,只是根据输入产生对应的输出。
实际情况会更复杂一些,比如“前视算法服务”中并不是完全无状态,如果需要做多帧融合或者目标跟踪,其结果跟多帧的数据相关。这多帧的历史数据就是状态。解决的办法是进一步拆解成更小的粒度。目标跟踪算法做成单独的服务,输入是所有关联帧的数据。
SOA服务划分的无状态和有状态的分离,在形式上与函数式编程范式中的纯函数与副作用的分离相对应,只是描述的是不同粒度上的架构问题。所以也可以在前视算法服务内部再做更细粒度的状态分离。
也就是说,向“前视算法服务”这样的轻量级状态可以通过内部或外部的进一步分解来做到真正的无状态。服务划分得过于零碎,会导致服务部署配置的难度和额外的通讯开销,但这可以通过其它的技术优化手段来解决,后文会详述。
图3. 6参与 ACC 功能的服务(简化图)
图中的“ACC会话服务”的状态就复杂的多。当用户启动ACC 功能,从功能激活到退出是一个完整的会话过程,会话的状态细节由状态机进行控制。这是典型的“
远程会话
”架构风格,这与一个Telnet 会话其实是非常相似的,都有一个会话生命周期过程。只不过在一辆车上,一个 ACC 会话同一时间只会出现一次,单辆车上不会出现同时多个ACC 会话实例。这个会话服务未必就没有办法是无法拆解成无状态的形式,但是会导致大量的状态数据在每次请求中传输,同时实现上没有状态机形式更自然,徒增复杂性。
设计某一个具体的车载SOA服务时,对服务状态分布的选择最好在强状态的“远程会话”和“无状态”两个极端风格中二选一。应该避免在客户端和服务端都维护状态数据。
从“关注点分离”的视角看,“无状态”化设计分离了状态“数据的存储与传输”和“状态数据的处理”两个关注点。
图3. 7 SOA:客户-服务器-状态分布
3.3.3 服务发现
复杂的软件系统被分解为大量小规模的服务后,服务之间也会有很多依赖关系,某个服务同时也会作为客户端访问其它服务。一个客户端访问另一个服务,需要知道该服务的访问点,对于TCP/IP 协议栈来说,至少包含IP和Port信息。同时,还需要知道该服务是否可用。因为服务可能还未启动,或者在启动中,或者因为某种原因停止了服务。
服务的访问点是可以通过配置文件静态配置的,如果系统中只有几个服务静态配置难度还不大,如果服务数量上升到几十个甚至更多,静态配置的维护难度就非常大。
某个服务启动时,为了它所依赖的服务已经就绪,就需要对服务的启动顺序进行管理。这对大量服务并存的系统也是很难做到的。
因此,我们给 SOA 架构风格增加一条约束
“每个服务具备能被其它服务发现的能力,也能查找需要使用的其它服务”。
所谓实现被其它服务发现的能力,意味着该服务应该至少具备一下两个能力:
1、服务可用性状态发生变化时能通知其它服务
2、响应对服务可用性的查询
第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:客户-服务器-状态分布-服务发现-发布订阅-代理-装配-监督
管理服务的“生命周期”是指要决定什么时候加载、启动服务,什么时候关闭、卸载服务。当系统中只有少数服务的时候这个问题可能不是很严重,简单的系统就是启动时所有服务都起来。但是当部署了几十上百个互相依赖的服务后,服务的“生命周期”问题就很重要了。尤其车载ECU需要对功耗进行控制,当前场景不需要的服务应该尽可能不启用。比如,泊车场景需要使用环视摄像头的图像识别车位线,当判断车辆行驶在高速公路上是,车位线识别的算法服务显然没必要加载。当车辆到达导航的目的地附近时,车位线识别服务可以被预先加载,但是不激活(执行算法识别),当用户启用泊车功能时,算法服务开始工作,泊车过程结束,算法服务停止计算。
服务“健康状态”的监督包括确定服务是否异常退出,服务所依赖的资源是否不可用而导致服务状态不正确。尤其是多个服务相互依赖时,服务的“健康状态”问题会顺着依赖链进行传播。在车载系统中,这跟故障诊断和功能安全密切相关。
即便服务在持续工作,但是它的“服务质量”是否满足要求也是需要被监督的。服务质量包括其响应时间,系统资源占用等等。对于检测出来的问题,“监督服务”需要决定处置措施,比如:重启、告警、功能降级等等。
图3. 12是在我们的SOA架构风格中增加的“服务监督”风格。监督服务本身也是一个SOA服务,只是有自己定义的标准化服务监督接口。所以图中“监督服务”继承自“服务器”。监督服务与服务容器和其它SOA服务通过监督接口进行交互。
3.3.8 RESTful API
对于互联网行业的开发人员来说,REST 以及 RESTful API 是司空见惯的事情。REST设计风格以及基于REST的HTTP协议是互联网软件架构基础。REST 是一个庞大话题,参考资料[23]中有详述,这里不多介绍。RESTful API 是基于REST 的原理,基于Http 协议实现的API 设计原则,遵循这些原则,可以设计出清晰简洁易于维护的API接口。更为重要的是,各种异构系统,如果能够对外提供基于 HTTP 实现的RESTful API,就可以在更大范围内做应用系统的集成。比如各大云服务提供商(AWS,阿里,百度等),都为它们的各种云基础设施提供了 RESTful API接口,我们就可以很方便的使用程序去管理我们的云端资源(如创建一台云主机,读取或更新数据库)。
图3. 13 SOA:客户-服务器-状态分布-服务发现-发布订阅-代理-装配-监督-Restful
现代智能网联汽车会与互联网有非常多的数据交互,这些交互不像车内通讯要求有很高的实时性,但是外部系统确有复杂的多样性,我们可以为某些服务提供RESTful API,以便能更好的与外部系统集成。图3. 12在 SOA架构风格中增加了RESTful API 约束。
RESTful API 设计有一些指导原则,可以参见 MicrosoftREST API Guidelines。这里做一些简要的说明。
设计RESTful API 首先要做好URI 的规划,需要把服务中的概念映射成合适的URI。比如我们给娱乐系统的音量设计一个 URI 来表示,那就可以对这个 URI使用 HTTP 的GET 和 PUT 方法来读取和设置音量值。
HTTP协议的操作方法很少,只有9个。RESTful API 最常用的方法主要是 GET、PUT、POST、DELETE,使用这几个方法就可以完成常见的CURD操作(create,update,read, delete)。这些操作方法如何映射到 SOA 服务的方法有一些基本的原则,这里结合SOME/IP 来做一些说明。
HTTP 的GET方法有一个要求,就是它不应该改变被调用的服务的状态,它只是读取一个URI的值,而不会改变它。PUT 方法有一个特点,其任意多次执行所产生的影响均与一次执行的影响相同,数学上管这个叫“幂等”(GET/PUT/DELETE 方法都是“幂等”的,但只有 GET不改变状态)。SOME/IP 协议中与之对应有同样特性的是Field。所以对于服务中的 Field 字段应该映射为 RESTful 的GET/PUT 方法进行操作。
SOME/IP 中的 Method 应该映射为对某个 URI 的POST 操作。RESTful推荐用良好的 URI 规划来更准确的表达领域的语义。所以比较合适的方式是每个Method有其URI,Method的参数和返回值体现在 POST 方法的提交数据和响应结果上。
SOME/IP 的 Event 映射为RESTful API 时比较麻烦,因为HTTP1.1协议是不支持向客户端主动通知的,不过有变通的WebSocket 方案,HTTP/2 是可以支持的,都需要由客户端向服务器发起GET 请求,服务器有需要向下通知的数据时就返回数据内容。
这些就是SOA 服务转换为 RESTfulAPI 的基本映射方式。一般来说并不需要为所有服务设计RESTful API,只为需要与外部系统集成的服务提供RESTful API即可。
3.3.8 可选的其它风格
“虚拟机/解释器”风格
这里的“虚拟机”指的是受控的代码执行环境,比如 JavaScript 虚拟机,Lua脚本解释器等。服务器向客户端下发一段代码,客户端在严格受控的执行环境中执行代码。这个受控的环境只能访问指定的资源,对资源的访问权限被限制在预定义的范围内。
对车载应用来说,对这种方式的需求往往出现在与云端有交互的场景。因为“虚拟器/解释器”可以先部署到车上,易变的需求可以后续由云端下发代码来满足,这在车载娱乐系统中会很常见。我们举一个为自动驾驶服务的数据采集场景来说明。
自动驾驶的很多算法以及测试场景非常依赖对数据的收集,相对于专业的采集车,量产汽车可以提供更为真实的数据案例,更广的覆盖范围。采集并上传哪些数据需要一些规则进行控制,否则没有针对性的大量数据上传会对带宽占用、数据存储、数据分析带来不利的影响。
可以在车辆量产时内置数据采集和上传的能力,以及检查采集规则的规则引擎。具体的采集规则由云端根据需要下发。比如视觉算法需要改进对雨雾天气的识别效果,就对出现雨雾天气的区域车辆下发采集规则的更新。车辆数据采集服务接收规则本地执行,触发数据采集事件。这样采集的数据内容可以根据需要随时调整,带来了较好的灵活性。这时规则引擎就相当与一个受限的解释器,下发的规则内容就是被执行的代码。
“远程求值”风格
“远程求值”风格跟“虚拟机/解释器”风格正好相反,是客户端把代码送到服务端执行。同样,这种方式的需求也出现在与云端有交互的场景。之所以把代码送到服务端执行,是因为执行所需要的数据在服务端。这些数据或者是因为数据量大不便传输,或者是因为数据安全或数据隐私的原因,不能被下发给客户端。客户端可以将代码发送到服务端执行,利用数据,取回结果。
这种方式在智能网联汽车的“车路云协同”上是有应用场景的。根据需要,联网的路侧单元至少可以保存道路沿线一定距离内的道路、车辆等信息,云端的服务器可以保存更大范围内的交通状况数据。这些数据都不方便直接发送给行驶中的车辆。当然路侧单元和云端服务器都可以根据自己保存的数据提供一些预定义的服务,供车端调用。但是更灵活的方式,是开放执行环境,由车端上传代码来决定如何利用数据。当然被执行代码的权限也会被限制,执行环境也会是一个受限的沙箱。
这种方式优点是能够定制服务器组件的服务,这改善了可扩展性和可定制性;代码直接在服务端执行,减少了服务器与客户端的交互能够得到更好的效率。由于客户端发送代码而不是标准化的查询,因此缺乏可见性。服务器如何信任客户端,如何控制执行环境的安全性也需要考虑。这会对服务的部署带来难度。
3.3.9 小结
这一节通过对SOA架构风格的推导,阐述了车载软件的SOA 风格并不是一个单一的架构风格,是一系列软件架构风格的组合。
对于车载软件,我们首先考虑的是如何降低其复杂性,划分为依赖性尽可能小的多个服务,是一种化整为零的方法。为了让服务尽可能简单,需要考虑服务的状态分布,强状态依赖的功能集中在特定服务,让其它服务以尽量以无状态的方式设计,以利于整体系统的开发、测试、复用。服务发现用来简化大量服务的配置,基于事件的发布订阅让服务之间的通讯偶合性降低。服务装配用于更好管理服务的部署,服务监督让服务的可靠性得到保障。RESRful API 增强车内服务与外部系统的互操作性。
这些软件架构风格很多都是在各个领域得到了广泛应用,以各种不同的形式存在。针对特定的应用场景,选择不同风格的组合,发挥各自的优势,往往能产生1+1>2 的效果。
3.4 SOA 的架构元素
前文3.1.1节提到,软件架构由三个方面组成,
架构元素
,架构的
组成形式
,和一些形成架构的
基本原则
。前文SOA推导时每一步都对此三个方面有一些体现。这里我们再从整体上来对架构元素的区分以及其组成形式做一些分析。
架构元素分为“处理元素”、“数据元素”、“连接元素”。
SOA 的“数据元素”比较容易识别,无论是RPC调用还是消息的发布订阅,都是数据在不同服务之间传递。深入理解这一点最好的方式是与面向对象的分布式系统做对比。面向对象的核心概念之一是“封装”,其关键含义在于将数据以及操作数据的方法封装在对象实例中,对象私有数据对外不可见。这可以理解为将“数据元素”与“处理元素”封装在了一起。面向对象的软件设计就更关注如何将领域概念转换成合适的对象模型,定义对象的行为和操作,以及对象之间的组成结构。
与面向对象的方法对比,SOA 架构中“数据元素”和“处理元素”的耦合度就低很多。基于消息的发布订阅完全是从数据的视角看世界,基本不关心数据如何被处理,只关注数据之间的供需关系。RPC请求可以被理解为“一对一”的数据供给与需求关系。尤其在无状态服务中,多个RPC请求之前没有状态上的关联性,SOA服务的数据处理能力就更为“纯粹”(函数式编程的中的概念,也称作无副作用)。大型系统在设计时,考虑问题的视角就是如何寻找合适的数据边界以界定服务边界,而不是对领域进行对象建模,这与分布式对象系统是完全不同的设计理念。与分布式对象的对比,在3.5.1节中还有进一步的讨论。
开发SOA架构中的“处理元素”是某个具体SOA服务的开发人员的职责,我们希望开发人员专注于这个具体数据处理的实现,比如某个AI算法,某个数据集的MapReduce过程。而数据从哪来,到哪去,开发人员不需要关心,这由SOA的“连接元素”来负责。
在实际工程实践中,SOA服务的开发者并不会完成全部的从数据处理到数据通讯的全部工作,而是要借助分布式中间件系统来实现。中间件提供Runtime库,IDL规范,程序语言特定的代码生成工具。使用IDL定义出数据通讯协议后,再用工具根据IDL生成代码。用户编写“处理元素”,使用IDL生成的代码与外部通讯。这时候,IDL生成的代码和中间件Runtime 就扮演了“连接元素”的角色。它从通讯通道接收数据传递给“处理元素”,将处理的结果发送给需求者。
一个SOA系统在整体设计时,架构师要关注“数据元素的定义”,“处理元素”的划分, 以及选择合适的“连接元素”将它们组合在起来。但当将某个明确的数据处理要求委托给某个团队(如:内部的某个算法团队,或者外部的供应商)开发时,架构师希望连接元素对与该团队是透明的。也就是说数据的处理不应该依赖于特定的连接方式。架构师甚至希望只需要提供给开发团队模拟的连接方式并回放预先录制的数据,开发团队基于这些来实现其数据“处理元素”。“处理元素”的完成品可以被架构师集成到真实的应用环境中,那时使用的可能是不同的连接元素。
中间件Runtime能够使用SOME/IP、DDS、共享内存等各种不同连接通道;工具根据IDL生成的代码完成用户代码和中间件Runtime的连接;服务发现让服务的位置不是固定的而是可以被配置、可动态发现的,这些都是在发挥“连接元素”的作用。
从“关注点分离”的视角看,SOA架构在三类主要的架构元素上实现了关注点分离,这也是它适合用于复杂系统集成的重要原因之一。
3.5 SOA相关其它问题讨论
3.5.1 SOA vs 分布式对象
分析 SOA,如果跟分布式对象做一些比较,可以更好的理解SOA的意义。我们在3.2 节提到的架构风格中有“分布式对象”风格。前文也提到CORBA 和 ZeroC ICE 都是分布对象的实现。
SOA 和分布式对象都是分布式网络架构的实现形式。只不过一个是 Service Oriented ,一个是 Object-Oriented。我们来看看他们有什么异同,为什么车载软件选择 Service-Oriented 而不是Object-Oriented。
面向对象(Object-Oriented)是非常重要的软件设计思想。当软件规模进一步复杂后,“结构化编程”方法也体现出一定的不足。面向对象的设计思想是解决这些问题的方法论之一。从C++开始,大多数程序设计语言都支持面向对象的方法论。它把数据和处理数据的方法封装在一个对象中,可以用来与具体的现实事物或抽象的语义概念进行对应。提供了对现实问题或语义概念进行建模的可能,用来描述复杂的软件语义。
程序语言创建的对象是本地对象,即访问对象的内部数据和接口方法都是当前进程内的行为,不涉及网络通讯。当面向对象的设计理念在本地编程获得成功后,人们很自然的会想到,在分布式领域中是不是可以使用类似的方法。一个对象封装了数据和操作方法,部署在某个服务器上,客户程序通过网络进行访问。在客户端也提供本地化的API接口,使用这些API时跟访问本地接口一样,但是请求会被自动代理到服务器上的某个对象。这就是分布式对象的由来。
CORBA 标准建立之初,人们曾经认为这将是未来主流的分布式技术。但实际上世界上最大的分布式系统万维网(WWW),并没有采用分布式对象。车载软件的分布式化选择了SOA,也没有选择分布式对象。我们从几个角度来探究其背后的原因。
强“状态相关”的服务不利于可扩展性
3.3.2 节讨论了程序状态在客户端和服务器端的分布情况对软件架构的影响。我们倾向于将服务设计成无状态的。这在WWW 的架构中尤为重要,这是WWW 世界能成为超大规模系统的关键原因之一。
分布式对象将数据与操作接口封装在一起,也意味着它将状态留在了服务器端。从这种意义上看分布式对象架构风格与远程会话风格有点接近,每一个远程的分布式对象自身就是一个小的会话。对于同一个远程对象的多次方法调用,都要精准的找到这个对象所在的位置。
对于 WWW 来说,这种方式会严重影响其可伸缩性。WWW 中,每一次请求的无状态特性可以让一个负载集群系统中的任何一个空闲的服务器都可以处理任何请求,而不需要一定让多个请求必须绑定到同一个服务器。
对于车载软件来说,虽然整体系统很复杂,但是到单个具体的服务点,其功能往往是很单一的,处理逻辑的复杂度远远小于电子商务等系统。很多时候就是接收数据,做简单处理,然后发送数据。面向对象的建模对这些简单功能来说带来了不必要的复杂度。无状态的服务设计方式能让系统大大简化。
不同对象的接口差异大
面向对象的设计方法很重要一点是对行业领域进行建模,分析出各种对象类型以及它们之间的关系。不同类型的对象就包含不同的状态数据以及不同的操作接口。
对象类型多、接口各不相同对于开发本地应用来说不是大问题,因为一般应用程序也就几十的对象类型,即便几百个也是在可以处理的数量级内。但是对于WWW 系统,可能会面临几千万甚至更高数量级的对象类型,没人能处理这么复杂的系统互操作。
WWW 采用的 REST 架构的一个关键设计约束是统一接口,实际只有GET, PUT, POST, DELETE等有限几个操作方法。仅仅这几个操作方法就能完成 WWW上各种复杂的功能,非常的神奇。奥妙在于WWW 使用URI(统一资源标识)重新定义世界。WWW上的每一个事物(简单的文件、图片;或者订单、人等各种语义概念)都可以使用一个 URI去表示。世界的复杂性从对象模型的关系,转换到了树形的URI表示,从而采用简单的操作方法完成所有可能的操作需求,如果不能满足,就再拆解出一个合适的URI形式。
对于车载软件来说,服务的类型数量也是在一个可控的数量级,并不需要URI机制去简化接口形式。但是如前文所说,当汽车软件需要与云端或者互联网体系进行数据交换时,可以把车载软件的部分服务包装成RESTful 接口遵循WWW系统的架构风格。
对象生命周期管理复杂
无论是本地对象还是分布式远程对象,都会有一创建、初始化、工作、失效、销毁的完整生命周期。对于分布式对象来说,其生命周期管理非常复杂。比如,当一个客户端请求某个对象的服务是,这个对象可能不存在,那么这种情况下如何处理也需要被考虑。
一般来说这种对象生命周期管理能力是由中间件系统来提供,CORBA,J2EE规范中都有对应的内容。会导致中间件实现的复杂度。
分布式对象系统,当然是希望以统一的方式管理大量的对象,比如成千上万的订单。只有这样才值得在开发复杂中间件实现时的投入。然而对于车载软件,很多软件模块恐怕只有一个对象需要被管理,比如上文的ACC 会话。一辆车同一时间内是不可能产生两个及以上ACC会话的。即便某些服务需要多个会话实例(相当于要管理多个对象的生命周期),那这个复杂性由服务自身去处理。
比如某个服务内部确实需要保存多个不同对象(或会话)的实例,那么可以提供类似“createXXX”的接口并返回对象的句柄,然后在其它的接口方法参数中带上这个句柄。服务实现时根据这个传入的句柄去找到对应的对象进行操作。至于对象的生命周期,服务开发者自己想办法维护。而不需要在中间件这一层提供架构级别的解决方案,因为投入与收获不匹配,并且带来不必要的复杂度。
3.5.2 RPC\消息通讯\管道 的技术关联
RPC 是“客户-服务器”架构风格实现请求响应的典型方式。“基于消息的通讯”(或者叫基于事件的集成、发布订阅模式)及“管道和过滤器”本身也是常用的架构风格。这三者是架构组件之间数据通讯的方式。从架构风格的组件耦合程度看,RPC 耦合度最高,管道模式次之,发布订阅耦合度最低。但在具体实现上,这三者之间很大程度上可以互为实现。
基于RPC 实现发布/订阅
如果我们实现了“客户-服务器”之间的RPC 机制,我们可以利用它来构建发布订阅系统。ZeroC ICE 支持的发布订阅服务IceStorm就是这么实现的。但是这个发布订阅是通过中心节点进行中转的。
图3. 14基于RPC 实现发布/订阅
如图3. 14所示,“接口A”定义了一个 RPC 接口,在普通的基于RPC的“客户-服务器”系统中,客户端直接调用接口A,服务器获取数据进行处理。当转换成“发布/订阅”模式时,引入中转服务:
1、中转服务提供基于主题(Topic,可以是一个字符串名称)的Publish和 Subscribe 接口。中转服务对每一个主题维护订阅者列表。
2、订阅者通过调用中转服务的Subscribe接口将自己注册到中转服务中,以订阅特定主题。实质上就是告诉中转服务自己对哪个主题感兴趣,告诉它回调自己的访问点(IP,端口等)。
3、发布者按主题发布数据,实际是对接口A的一次RPC调用,但是带上了主题名称,方便中转服务识别。
4、中转服务并不识别并执行发布者的请求,只是根据主题名称将请求转发给订阅者。接口的适配(参数定义,版本匹配)由发布者和订阅者自己保证。
以上实现“发布/定义”方式的缺点是有中转服务,一方面存在单点失败的可能,一方面两次数据传输有额外的网络性能开销。另外只适合没有返回值的RPC调用。优点也很明显,可以方便的把 RPC 服务转换成“发布/订阅”模式,能够方便的达到解耦和发布者和订阅者的目的。中转服务是与特定接口无关的,也就是说任何RPC接口都可以这么转换。因为中转服务不需要识别接口内容,只是单纯的做数据报文的转发,不做序列化和反序列化动作,所以可以适用于任何单向RPC接口。
基于发布订阅实现RPC
反过来,我们也可以基于“发布/订阅”的消息通讯实现 RPC机制。“发布/订阅”逻辑上是实现一个“多播”机制。多个软件组件可以订阅某一个主题的消息,不关心谁发送;多个组件也可以发出某个主题的消息而不关心谁会接收。既然可以“多播”,当然也可以“单播”,我们完全可以给用户层一个RPC的API,而在实现的时候把RPC调用转化成单播的消息通讯,比如利用DDS来实现。
基于“发布/订阅”来实现管道
在管道和过滤器风格中,每个组件(过滤器)从其输入端读取数据流,对数据进行处理后在其输出端产生数据流。每个过滤器必须完全独立于其它的过滤器(零耦合),它不能与其它过滤器在其上行和下行数据流接口分享状态、控制线程或其它资源。“管道和过滤器”有非常好的可配置性,可扩展性。
如果我们把管道中每一个过滤器的输入和输出数据流定义成“发布/订阅”的消息,那么也可以实现管道和过滤器的风格形式。实际很多基于ROS/ROS2 实现的系统就是这么用的。
以上几个是架构风格在实现层面是交叉支持的具体形式,其实还可以有更多。如前文所述,架构风格定义的是软件组件之间结构关系的约束形式。每一个架构风格当然有其最优的原生实现形式,但是也不排除在具体实现技术上基于其它风格实现。
3.5.3车载以太网助力 SOA架构
SOA 在分布式系统中实际上已经被应用很多。不过在以太网用于汽车之前,车载软件其实是不怎么提 SOA的。SOA的相关设计约束并不是说一定要基于以太网,只是在车载软件上,以太网能让SOA 更好的实现并发挥作用。对此我们做一些说明。
先明确讨论中“以太网”的概念。当我们谈及“以太网”的时候,根据上下文其实往往有“狭义”和“广义”两种含义。
狭义的以太网重点关注的是以太网的物理层和链路层。这时候我们指的以太网是符合 100BASE-T、1000BASE-T等标准的有线网络,采用总线型拓扑,或者基于交换机实现星型拓扑。使用CSMA/CD(Carrier Sense Multiple Access/Collision Detection,即载波多重访问/碰撞侦测)的总线技术来解决通讯冲突。在这个语义下,WiFi 不是以太网,它是无线通讯技术,有另一套标准(IEEE 802.11)。
广义的“以太网”包含了常用的通讯协议,最核心的是对IP 层协议的支持。ISO/OSI 定义的七层网络模型中,物理层和链路层之上是 IP 层(网络层)。传输层协议(TCP,UDP)也都是基于 IP 层。IP成定义了数据报文进行地址和传输的协议,无论下面两层如何实现,只要有IP层的支持,不同网络就是互通的。在这个语义下,WiFi 也是以太网,因为它也支持 IP 协议。我们还可以实现 IP over USB, 那就是基于 USB 的以太网。
下面讨论中的“以太网”指的是广义的以太网,即具有IP协议支持的网络。
Can 总线在汽车中的到广泛的运用,Can总线只有物理层和数据链路层([27]3.3)。使用 Can 总线的应用需要在这个基础的数据链路层协议上去定义自己的数据格式,一般以一个DBC 格式文件描述。Lin总线成本更低, FlexRay 总线提供了比 Can 高得多的数据传输带宽,但是他们也跟 Can 总线一样,只有物理层和数据链路层,应用层协议各自为政。这意味着这几个网络是无法互通的。汽车电子电器架构设计的时候,解决互通问题的办法就是两个网络中间加网关。网关同时支持两个以上网络并来回搬运数据。即使是两条Can 总线想要互通也需要加网关,而且网关的数据搬运代码都需要单独定制,因为每条Can的应用层协议都不一样。
设想一下,在这种网络环境下做SOA服务是什么效果。假如我们基于 Can 协议实现了一个 SOA 服务,我们没法进行RPC请求,因为 Can 协议没有寻址的概念,请求不知道发给谁。不过好消息是Can 本质上也是发布订阅的机制,我们可以放弃单播通讯,只做事件广播。但 Can 一个消息只有8个字节,没有地方放更多的头信息,我们在设计服务发现机制的时候会遇到巨大挑战。就算我们设计出了服务发现,对不起,这个服务在别的网段中不会被发现,因为各个网段的服务是不能互通的。我们就需要开发网关程序跨网段搬运数据。但是每增加一个服务,或者对服务的数据格式做些修改,网关程序都要重新修改适配。就算这些都完成了,我们想让一个开发好的服务在另一个项目中复用几乎不可能,因为对应的Can 协议,网关程序全部都要重新适配。
引入以太网技术带来的 IP 层是解决这些问题的关键。不管各段网络的物理层和链路层是什么样的,只要支持 IP层协议,IP报文就可以在不同网段之间传输。IP 协议是可以支持广播和多播(一次数据发送,多个目标接收)的。而且广播和多播是可以跨网段的,有成熟的协议支持。广播和多播可被用于SOA 的“服务发现”和服务之间的数据发布订阅。
以太网比大多数其它车载网络提供了更高的带宽,目前常用的车载以太网系统基本都可以达到1000Mbps。将来升级到万兆甚至更高的光纤以太网也是指日可待。而这种升级在软件架构上几乎不需要做太多变化就可以利用网速提高带来的好处。这样在数据报文设计上就不用像设计Can报文一样精打细算的利用好每一个 bit。必要的情况下可以增加更多的头信息支持更多的功能。也可以用来传输图像等多媒体数据。扩大了SOA的服务能力范围。
TCP/IP 协议发展了很多年,是互联网的技术基础。衍生出了无数成熟的网络技术,这些都可以适当调整后运用到车载软件。比如基于发布订阅的DDS技术,提供了丰富的 QoS能力,可以用于支持服务组件之间的通讯;与外部系统集成可以使用基于HTTP相关的技术。这些技术的有效利用可以很快的提供更多丰富的功能。
引入以太网也有一些其它问题需要解决。以太网的工作模式就是多个联网节点上的多个应用争抢网络带宽。某个应用的高带宽占用可能会导致另一个应用的紧急数据不能及时传输,影响车内服务之间通讯的实时性。TSN (时间敏感网络)相关协议的就是用来解决这些问题。
总而言之,在车载软件中,以太网技术是支持车载SOA架构风格的关键技术基础。
3.5.4 SOA 与 微服务
在汽车软件开始向 SOA 架构转型时,大型互联网服务基本上都已经转向了微服务架构风格。那么,SOA与微服务的异同点是什么?汽车软件也会走向微服务架构吗?
规模引起的量变到质变
首先SOA与微服务在架构风格的逻辑上是一脉相承的,前文对SOA的论述对微服务同样有效。但是微服务在架构风格上更进一步,可以理解为至少增加了以下几个约束:
1、服务粒度尽可能小
2、服务之间的依赖性更小
3、更彻底的去中心化
服务粒度尽可能小可以理解为比SOA更小的服务拆分,强调的重点是业务系统彻底的组件化和服务化,原有的单个业务系统会拆分为多个可以独立开发,设计,运行和运维的小应用,这些小应用之间通过服务完成交互和集成。每个服务完全不依赖中心化的资源,比如不依赖中心化的数据库,每个服务有自己的数据存储机制。粒度越小、相互之间依赖越小,无中心化,让开发、测试、部署的独立性越强,越容易使用负载均衡技术支持高度的并发访问。
相对于SOA ,微服务是一个量变引起质变的过程。目的是为了支持更大型的互联网服务体系,应对高并发、高可用要求、高业务复杂性的挑战,同时要求开发迭代更为敏捷迅速,部署简单并高度自动化。像电商的秒杀系统,12306 购票系统,都是极限并发的典型案例,微服务架构在应对这些场景的时候有很好的表现。
微服务可以认为是SOA架构向更深度的发展进化。但是互联网的微服务架构和车载的SOA架构,应用场景有很大的差别。虽然汽车软件也是分布式架构,而且车载以太网技术得到应用后,很多车载分布式技术与互联网的分布式技术有非常多的共通之处。但是车载软件的分布式规模与互联网服务完全不在一个量级。
“分布”与“集中”
大型互联网服务部署规模可能涉及上百台服务器,每秒百万级的并发。车载的分布式服务只在一台车的局部系统中,并发要求低但实时性要求高。与互联网的极度分布式趋势不同,车载系统是反过来,目前是向集中式发展。原来电子电气架构中大量小控制器实现的功能,逐步向几个主要的高性能域控制器集中。
车载SOA架构在功能划分上尽量切分成多个服务,但是部署的时候实际是集中化的,同时通过一系列技术手段优化通讯延迟。有意思的是,这个“集中”其实是与“分布式”并存的。
图3. 15物理的集中与逻辑的分布
图3. 15是一个理想的域控制器内的服务部署。高性能的多核心SoC被虚拟化成了多个虚拟机,VM1和VM2分别是Linux和QNX系统,而且都支持容器化(Docker Enable)。每个虚拟机内又有多个容器(Docker Container,不是前文服务装配中的服务容器 )。每个容器是一个独立的 OS系统,里面再部署SOA服务进程,进程内有多个SOA服务。不同虚拟机内、不同容器内的服务通过网络进行通讯。
我们可以看到,整体的域控制器中的软件在物理上是“集中”部署到了同一个域控制器主机,但是逻辑上又“分布”到了不同的操作系统实例。这样的好处是可以使用虚拟机或Docker容器作为供应商的边界。一个供应商提供一个虚拟机或容器内的全部服务,与其它供应商互不影响,责任边界也容易确定。为了优化通讯我们可以在虚拟化这一层提供PCIe总线的虚拟化来支持跨虚拟机的共享内存通讯。
虚拟化技术和容器技术在云端计算中心已经被大规模使用,车载系统只是在复刻这个过程。差别仍然在于规模,现实应用中微服务架构基于的虚拟机和容器数量比车载系统至少高出两个数量级。而且虚拟化对微服务架构是完全透明的,微服务架构中的服务认为自己运行在独立的OS中,在微服务架构中,只有极度的“分布式”,没有“集中”部署的含义。
技术实现上的侧重点不同
两者的应用场景不同决定了分布式规模不同,导致在具体的设计和实现上的侧重点也有很大差别。
车载SOA首要解决的是服务能够被拆分,拆分之后能够通讯,然后解决如何优化通讯的效率,尤其是一定的实时性保证。
微服务架构因为彻底的分布式带来的大量微服务组件,需要在更大的规模上去解决“负载均衡、服务发现、认证授权、监控追踪、流量控制、服务部署、分布式事务、分布式存储”等等问题。以目前最先进的微服务架构Service Mesh来说,2017 年1月发布的Service Mesh产品Linkerd,所有的请求都通过 Service Mesh 转发,不提供直连方式,它掌控所有的流量。2017 年 5 月, Google、IBM、Lyft 联手发布了 Istio,它与第一代 Service Mesh 相比,增加了控制平面,它具备远超第一代的控制能力。通过集中式控制面板,加上所有流量均会通过 Service Mesh 转发,通过 Service Mesh 的控制面板,就可以控制所有整个系统。Service Mesh管控的内容出来服务注册和服务发现外,还包括负载均衡机制,弹性的流量控制能力(熔断、限流、降级、超时、重试等)。而转发引起的消息延迟在互联网业务中并不是最重要的关注点。
车载软件架构会走向“微服务”吗
车载SOA架构是实际上跟互联网技术上的SOA架构也是有很大差别的,加入了很多为车载场景定制的协议和优化机制。微服务架构作为SOA的进一步演进,已经在互联网领域体现了其价值所在。车载的SOA架构也不会一成不变,也会进一步的演化,很自然的也会从微服务架构中吸取优秀思想。但不会是直接使用互联网微服务的产品。
目前来说车载软件的当务之急还是先实现在业务功能层面SOA化,然后在服务部署上应用虚拟化和容器化,以支持不同粒度上的服务部署,并建立供应商的责任边界。进一步的改进可以根据现实问题来做响应。有一个现成微服务架构作为参照,也为后续架构改进的思路提供了技术储备。
**
后台回复“
AES07
”,获取PDF完整版资料
—
END
—