死生之地不可不察:论 API 标准化对 Dapr 的重要性

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第1张图片

原文出处:https://www.infoq.cn/article/wjkNGoGaaHyKs7xIyTSB
作者:敖小剑
发表时间:2021 年 11 月 18 日 19:11

Dapr 作为新兴的云原生项目,以"应用运行时"之名围绕云原生应用的各种分布式需求,致力于打造一个通用而可移植的抽象能力层。这个愿景有着令人兴奋而向往的美好前景,然而事情往往没这么简单,API 的标准化之路异常的艰辛而痛苦,Dapr 的分布式能力抽象在实践中会遇到各种挑战和困扰。

快速回顾:什么是 Dapr?

Dapr 的全称是 “Distributed Application Runtime”,即 “分布式应用运行时”, 是一个由微软发起的开源项目,目前已经进入 CNCF 孵化器。

我们首先来对 Dapr 进行一个简单的介绍。首先来看 ServiceMesh,和传统 RPC 框架相比,ServiceMesh 的创新之处在于引入了 Sidecar 模式:

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第2张图片

而 ServiceMesh 只解决了服务间通讯的需求,而现实中的分布式应用存在更多的需求,Multi-Runtime 理论将这些需求归结为四大类:

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第3张图片

在 ServiceMesh 初步成功并且 Sidecar 模式被云原生社区普遍接受之后,各企业效仿 ServiceMesh 将应用需要的其他分布式能力外移到各种 Runtime,这逐渐演变成了一个趋势。这些 Runtime 会逐渐整合,最后和应用 Runtime 共同组成微服务,形成所谓的 “Multi-Runtime” 架构,又称 Mecha:

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第4张图片
Dapr 项目是目前业界第一个 Multi-Runtime / Mecha 实践项目,下图来自 Dapr 官方,比较完善的概括了 Dapr 的能力和层次架构:

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第5张图片

本质差异:Dapr vs ServiceMesh

在快速回顾完 Dapr 之后,我们来正式展开本文的内容。首先需要回答这样一个问题:Dapr 和 ServiceMesh 有什么区别?

这个问题也是大多数读者在了解 Dapr 之后最常问到的一个问题,原因是 Dapr 和 ServiceMesh 在架构上实在太像了,都是以 Sidecar 模式为核心。

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第6张图片
Dapr 的基本思路也是和 ServiceMesh 保持一致:通过引入 Sidecar 来实现 关注点分离 + 独立维护

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第7张图片

Dapr 和 ServiceMesh 最明显的不同之处是 Dapr 的场景比 ServiceMesh 要复杂

ServiceMesh 关注于服务间通讯,即下图中虚线部分;而 Dapr 除了服务间通讯之外,还适用于诸多其他的场景,如状态存储、消息的发布订阅、资源绑定、配置等。

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第8张图片
下图是 Dapr 目前已有的构建块,以及对他们所提供的能力的简单描述(其中 Service Invocation 对应 ServiceMesh 的服务间通讯能力):

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第9张图片

功能的差异是很直白的,容易理解。但是,如果只是功能上的差异,那么问题来了:如果 ServiceMesh 也提供同样的能力,是不是就和 Dapr 一样了?

我们来看 Envoy 的功能,Envoy 原生支持 HTTP1.1/REST 和 HTTP2 / gRPC 协议,社区增加了对 Dubbo、Thrift 等 RPC 协议的支持,而 Envoy 也在提供对 Kafka、Redis 等原生协议的支持,未来 Envoy 提供更多原生协议支持也是可以预期的。那是不是意味着随着功能的增加,以 Envoy、Istio 为代表的 ServiceMesh 就和 Dapr 一样了呢?

答案当然是否定的—— Dapr 和 ServiceMesh 的本质差异在于其【工作模式】。ServiceMesh 的工作模式是【原协议转发】

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第10张图片
ServiceMesh 的 Sidecar 接收到是 App 发出的原生协议的请求,然后转发给另一端的 Sidecar 进而转发给目标 App;或者,对于 Redis/Kafka 等的支持是 Sidecar 将原协议转发给 Redis/Kafka 服务器。在这个过程中,ServiceMesh 的 Sidecar 原则上不修改协议,只做 转发(“Forwarding”),Sidecar 扮演的是 代理(“Proxy”)的角色。

而 Dapr 的工作模式是 能力抽象

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第11张图片
Dapr Sidecar 接收到是 App 发出的遵从标准 API 的请求,这些标准 API 对能力进行了抽象;对于服务间通讯的场景,Dapr Sidecar 会将请求转发给另一端的 Dapr Sidecar;对于服务间通讯之外的能力的支持则需要将标准 API 转换为底层组件对应的原生协议。在这个过程中,Dapr Sidecar 原则上对应用只暴露抽象之后的分布式能力,屏蔽了底层具体的实现和通讯协议,不做"转发"而是提供"能力",Dapr Sidecar 扮演的是运行时 (“Runtime”) 的角色。

工作模式不同的背后,反映出 Dapr 和 ServiceMesh 在设计目标上的差异:

  • ServiceMesh 的主要设计目标是 低侵入,采用原协议转发的方式可以尽可能的降低对应用的侵入,为了达到无侵入的目标甚至采用了流量劫持的方式;
  • Dapr 的主要设计目标是 可移植性,即在跨云跨平台的前提下实现无厂商绑定,采用的方式是在将分布式能力抽象为标准 API,并在各种开源项目和云平台上提供这套标准 API 的不同实现,从而达到在不同平台上运行的目标,这即 Dapr 愿景中的 “anywhere”;

总结一下 Dapr 和 ServiceMesh 的差异以及适用场景

ServiceMesh 强调对应用无侵入,支持原协议转发和流量劫持,不仅仅适用于新应用,相比 Dapr 也更加适用于老应用。而 Dapr 提供 “标准 API”、“语言 SDK” 和 “Runtime”,需要应用进行适配(这意味着老应用需要进行改造),侵入性比较大。因此 Dapr 更适合新应用开发 (所谓 Green Field),对于现有的老应用(所谓 Brown Field)则需要付出较高的改造代价。但在付出这些代价之后,Dapr 就可以提供跨云跨平台的可移植性,这是 Dapr 的核心价值之一。

因此,在决策是否该采用 Dapr 时,可移植性是一个非常关键的考虑因素

死生之地:API 标准化的价值

在上一篇文章 《Dapr v1.0 展望:从 ServiceMesh 到云原生 》 中,我曾经指出: Dapr 的本质是面向云原生应用的【分布式能力抽象层】
死生之地不可不察:论 API 标准化对 Dapr 的重要性_第12张图片

可移植性 是 Dapr 的重要目标和核心价值。Dapr 的愿景, “any language, any framework, anywhere”,这里的 anywhere 包括公有云、私有云、混合云和边缘网络。

而 Dapr 可移植性的基石在于 标准 API + 可拔插可替换的组件,下面这张来自 Dapr 官方网站的图片非常形象的展示了 Dapr 的这一特性:

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第13张图片

从架构设计的角度看,Dapr 的精髓在于:通过抽象 / 隔离 / 可替换,解耦能力和实现,从而实现可移植性。

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第14张图片

在传统的应用开发方式中,应用需要面向具体的实现编程,即当应用需要使用到某个能力时,就需要找到能提供该能力的底层组件,如上图中的 Redis / Consul / Memcached / Zookeeper 都可以提供分布式状态的存储能力。应用在选择具体组件之后,就需要针对该组件进行编程开发,典型如引入该组件的客户端 SDK,然后基于这些 SDK 实现需要的分布式能力,如缓存、状态、锁、消息通讯等具体功能。

Dapr 倡导 “面向能力编程"

在 Dapr 中,Dapr 倡导 “面向能力编程",即:

  • Dapr API 提供了对分布式能力的抽象,并提取为标准 API
  • Dapr 的 Runtime 隔离 应用和底层组件的具体实现

而这些组件都是可替换的,可以在运行时才进行绑定。

Dapr 通过这样的方式,实现了能力和实现的解耦,并给出了一个美好的愿景:在有一个业界普遍认可并遵循的标准化 API 的基础上,用户可以自由选择编程语言开发云原生,这些云原生可以在不同的平台上运行,不被厂商和平台限制——终极目标是使得云原生应用真正具备跨云跨平台的可移植性。

理想很美好,但现实依然残酷:和 ServiceMesh 相比,Dapr 在落地时存在一个无可回避的问题——应用改造是有成本的。

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第15张图片
从落地的角度来看, ServiceMesh 的低侵入性使得应用在迁移到 ServiceMesh 时无需太大的改动,只需要像往常一样向 sidecar 发出原生协议的请求即可,甚至在流量劫持的帮助下可以做到应用完全无感知。从工作模式上说,基于原生协议转发的 ServiceMesh 天然对旧应用友好。而 Dapr 出于对可移植性目标的追求,需要为应用提供一个标准的分布式能力抽象层来屏蔽底层分布式能力的具体实现方式,应用需要基于这个抽象层进行开发,才能获得跨云跨平台无厂商绑定等可移植性方面的收益。因此,在 Dapr 落地过程中,新应用需要基于 Dapr API 全新开发,老应用则不可避免的需要进行改造以对接 Dapr API。

API 标准化是 Dapr 成败的关键,为 Dapr 的发展建立起良性循环:

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第16张图片

  1. API 标准化:定义 Dapr API,对某一个分布式能力进行良好的抽象,覆盖日常使用的大部分场景,满足应用的常见需求;
  2. 提供组件支持:基于标准 Dapr API,为开源产品和公有云商业产品提供支持组件,覆盖主流产品和厂商;
  3. 具备可移植性:基于标准 Dapr API 开发的应用,可以在主流开源产品和公有云商业产品之间自行选择适合的组件,不受平台和厂商的限制;
  4. API 得到更多认可:可移植性为 Dapr 构建核心价值,Dapr API 得到更多的认可,逐渐成为业界的事实标准;
  5. 更广泛的组件支持:Dapr API 越接近业界标准,就会有越多的产品和厂商愿意提供支持 Dapr API 的组件;
  6. 可移植性更强:越来越多的组件支持,可以覆盖更多的开源产品和厂商,从而更接近 Anywhere 的愿景;

理想情况下, “标准化” / “组件支持” / “可移植性” 之间的相互促进和支撑将成为 Dapr 发展源源不断的动力。反之,如果 API 标准化出现问题,则组件的支持必然受影响,大大削弱可移植性,Dapr 存在的核心价值将受到强烈挑战。

左右为难:取舍之间何去何从

既然 API 标准化如此重要,那 Dapr 该如何去定义 API 并推动其标准化呢?我们以 Dapr State API 为例,介绍在 API 定义和标准化过程中常见的问题。

State API 的基本定义

State 形式上是 key-value 存储,即状态信息被序列化为 byte[] 然后以 value 的形式存储并关联到 key,当然实践中非 k-v 存储也可以实现 State 的功能,比如 mysql 等关系型数据库。Dapr 的 State API 的定义非常简单明了,除了基于 key 的 CRUD 基本操作外,还有 CRUD 的批量操作,以及一个原子执行多个操作的事务操作:

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第17张图片
上述 API 定义貌似非常简单,毕竟 k-v 基本操作的语义非常容易理解。但是,一旦各种高级特性陆续加入之后,API 就会逐渐复杂:数据一致性 / 并发保护 / 过期时间 / 批量操作等。

State API 的高级特性

以 GetState() 为例,我们展开 GetStateRequest 和 GetStateResponse 这两个消息的定义,了解一下数据一致性 / 并发保护这两个高级特性:

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第18张图片
可以看到 GetStateRequest 中的字段 key 和 GetStateResponse 中以 bytes[] 格式定义的字段 data,对应于 key-value 中的 key 和 value。GetState() 的基本语义非常明显的呈现:请求中给出 key,在应答中返回对应的 value。除此之外,在 API 的设计中还有三个字段:

请求中的 consistency 字段用于数据一致性:
死生之地不可不察:论 API 标准化对 Dapr 的重要性_第19张图片
当组件支持多副本时,consistency 字段将用于指定对数据 一致性的要求 ,其取值有两种:eventual:(最终一致性)strong:(强一致性)。除了 getState() 方法外,这个参数也适用于 saveState() 和 deleteState() 方法。

应答中的 etag 字段用于并发,实现乐观锁(OCC)

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第20张图片
乐观锁的工作原理如上图所示,假定有三个请求同时查询同一个 key,三个应答中都会返回当前 key 的 etag(值为"10”)。当这三个线程同时进行并发修改时,在 saveState()的请求中需要设置之前获取到的 etag,第一个 save 请求将被接受然后对应 key 的 etag 将修改为"11”,而后续的两个 save 请求会因为 etag 不匹配而被拒绝。

etag 参数在 getState() 方法中返回,在 saveState() 方法中设置,每次对 key 进行写操作都要求必须修改 etag。

concurrency 参数在 saveState() 方法中设置,有两个值可选:first-write-wins(启用乐观锁) 和 last-write-wins(无乐观锁,简单覆盖)。

请求和应答中都有的 metadata

类型定义为 map,可以方便的传递未在 API 中定义的参数,为 API 提供扩展性:即提供实现个性化功能(而不是通用功能)的扩展途径。

对批量操作的处理

State API 提供的批量操作,用于一次性操作多个 key,和应用多次调用单个操作的 API 相比,减少了多次往返的性能开销和延迟。考虑到组件原生对批量操作的支持程度,Dapr 中的批量操作的实现方式有两种:

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第21张图片

  • 原生支持批量操作:Dapr 组件将多个 key 一起打包提交给组件的后端实现,此时批量操作的实现由后端完成,Dapr 只是简单转发了多个 key;
  • 原生不支持批量操作:Dapr 组件将多次调用组件的后端实现,此时批量操作的实现由 Dapr 组件完成;

展开细节看一下 Dapr 中 GetBulkState() 方法的代码实现,忽略细节代码和加密处理,只看主体逻辑:

func (a *api) GetBulkState(ctx context.Context, in *runtimev1pb.GetBulkStateRequest) (*runtimev1pb.GetBulkStateResponse, error) {
    // try bulk get first
    bulkGet, responses, err := store.BulkGet(reqs)

    // if store supports bulk get
    if bulkGet {
        return bulkResp, nil
    }
    
    // if store doesn't support bulk get, fallback to call get() method one by one
    limiter := concurrency.NewLimiter(int(in.Parallelism))
    n := len(reqs)
    for i := 0; i < n; i++ {
        fn := func(param interface{}) {
            req := param.(*state.GetRequest)
            r, err := store.Get(req)
            item := &runtimev1pb.BulkStateItem{
                ......
            }
        }
        limiter.Execute(fn, &reqs[i])
    }
    limiter.Wait()
    ......
}

Dapr 的 GetBulkState() 方法先尝试调用组件实现的 BulkGet(),如果组件支持批量操作则直接返回结果。而当组件不支持批量操作时,GetBulkState() 方法会做两个事情:

  1. 兜底:Dapr 通过多次调用单个 getState() 方法来模拟实现批量操作,对于应用来说是没有感知的;
  2. 优化:如果只是简单的循环调用,当 key 比较多时延迟累加会比较大,因此 Dapr 做了一个并行查询的优化,容许启动多线程同时发起多个查询,然后将结果汇总起来后再一起返回;

Dapr 这样做的好处是:对于支持批量操作的组件可以充分发挥其功能,同时对于不支持批量操作的组件由 Dapr 模拟出了批量操作的功能并提供了基本的性能优化。最终使得批量操作的 API 可以被所有组件都支持,从而让使用者在使用批量 API 时可以有统一的体验。

对事务操作的处理

相对于批量操作的简单处理方式,事务的支持在 Dapr 中就要麻烦的多,是目前 State API 在实现中最大的挑战,其根源在于:很多组件不支持事务!而且,事务性也无法像批量操作那边在 Dapr 侧进行简单补救。

以下是实现了 Dapr State API 的组件对事务支持的情况:

支持事务的组件有:
Cosmosdb、Mongodb、Mysql、Postgresql、Redis、Rethinkdb 和 Sqlserver。

不支持事务的组件有:
Aerospike、Aws/dynamodb、Azure/blobstorage、Azure/tablestorage、Cassandra、Cloudstate、Couchbase、Gcp/firestore、Hashicorp/consul、Hazelcase、Memcached 和 ZooKeeper。

因此,Dapr State API 的组件被是 否支持事务 分成了两大类。这些组件在开发时和运行时调用上需要就是否支持事务进行区分:

  1. 组件在初始化时需要指明是否支持事务;

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第22张图片

  1. Dapr 在启动时进行过滤,支持事务的组件单独放在一个集合中;

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第23张图片

  1. Dapr 在收到事务请求时,会检查当前组件是否支持事务;

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第24张图片

这直接导致了一个严重的后果:当用户使用 Dapr State API 时,就必须先明确自己是否会使用到事务操作,如果是,则只能选择支持事务的组件。

残酷的现实:高级特性的支持度

在前面我们讲述 API 标准化的价值 时,是基于一个 基本假设在能力抽象和 API 标准化之后,各种组件都可以提供对 Dapr API 的良好实现,从而使得基于这些标准 API 开发的应用在功能得到满足的同时也可以获得可移植性

这是一个非常美好的想法,但这个假设的成立是有前提条件的:

  1. API 定义全部特性:即 API 提供的完整的能力,包括各种高级特性,从功能的角度满足用户对分布式能力的各种需求;
  2. 所有组件都完美支持:每个组件可以完整的实现 API 抽象和标准化的这些能力,不存在功能缺失,从而保证在任意一个平台上都可以以相同的体验获取同样的功能;

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第25张图片

而现实是残酷的:特性越是高级,就越难于让所有组件都支持。以 Dapr State API 为例,如上图所示从做向右的各种特性,组件的支持程度越来越差:

  • 基本操作:这些是基本的 KV 语义,CURD 操作,而且是每次操作单个 key。所有组件都支持,支持度=100%;
  • 批量操作:在基本操作的基础上增加对多个 key 同时操作的支持,部分组件不能原生支持,但是 Dapr 可以在单个的基本操作上模拟出批量操作来进行弥补,因此也可以视为都支持,支持度~=100%;
  • 过期时间:可选特性,设置过期时间可以让 key 在该时间之后自动被清理,有部分组件原生支持这个特性,但也有部分组件无法支持。这是一个可选特性,Dapr 的设计是通过在请求中提供名为 TtlInSeconds 的 metadata 来指定;
  • 并发支持:乐观锁机制,要求组件为每个 key 提供一个 etag 字段(或者称为 version),每次修改时都要比对 etag,修改后要更新 etag。这个特性也是只有部分组件支持,需要在组件支持特性中明确指出是否支持;
  • 数据一致性:容许在请求中提供参数指定操作对数据一致性的要求,可以是强一致性或最终一致性,组件如果支持就可以依照这个参数的指示进行操作。这个特性同样只有部分组件支持;
  • 事务:提供对多个写操作的原子性支持,只有部分组件支持(按照前面列出来的组件支持情况,大概是 40%),需要在组件支持特性中明确指出是否支持;

但实际上,在 API 定义和标准化的过程中,我们不得不面对这样一个残酷的现实:API 定义全部特性所有组件都完美支持 无法同时满足!

这导致在定义 Dapr API 时不得不面对这么一个痛苦的抉择:向左?还是向右?

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第26张图片

  • 向左,只定义基本特性,最终得到的 API 倾向于功能最小集;
    优点:所有组件都支持,可移植性好 缺点:功能有限,很可能不满足需求;

  • 向右,定义各种高级特性,最终得到的 API 倾向于功能最大集;
    优点:功能齐全,很好的满足需求 缺点:组件只提供部分支持,可移植性差;

API 定义的核心挑战

Dapr API 定义的核心挑战在于:功能丰富性和组件支持度难于兼顾

如下图所示,当 API 定义的功能越丰富时,组件的支持度越差,越来越多的组件出现无法支持某个定义的高级特性,导致可移植性下降:
死生之地不可不察:论 API 标准化对 Dapr 的重要性_第27张图片
在 Dapr 现有的设计中,为了在标准 API 定义之外提供扩展功能,引入请求级别的 metadata 来进行自定义扩展:Dapr 现有的各种 API,包括上面我们详细介绍的 State API,基本都经历过这样一个流程:

  • 每个 Dapr 构建块的 API 在初始创建时,通常会从基本功能开始,相对偏左侧;
  • 随着时间的推移,为了满足更多场景下的用户需求,会向右移动,在 API 中增加新功能;
  • 新增的功能可能会导致部分组件无法提供支持,损害可移植性;

因此 Dapr API 在定义和后续演进时需要做权衡和取舍:

  • 不能过于保守:太靠近左侧,虽然可移植性得以体现,但功能的缺失会影响使用;
  • 不能过于激进:太靠近右侧,虽然功能非常齐备,但是组件的支持度会变差,影响可移植性;

Metadata 的引入和实践

在 Dapr 现有的设计中,为了在标准 API 定义之外提供扩展功能,引入请求级别的 metadata 来进行自定义扩展:

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第28张图片
metadata 字段的类型定位为 map,可以方便的携带任意的 key-value,在不改变 API 定义的情况下,组件和使用者可以约定在请求级别的 metadata 中通过传递某些参数来使用更多的底层能力。

下图是在阿里云在内部落地 Dapr 时,对 Dapr State API 的各种 metadata 自定义扩展:

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第29张图片

注意:State API 中,expire 的功能在通过名为 ttlInSeconds 的 metadata 来实现,而没有直接在 getStateRequest 中定义固定字段。

metadata 的引入解决了 API 功能不足的问题,但是也造成了另外一个严重问题:破坏可移植性。

  1. 可移植性是 Dapr 的核心价值:因此定义 Dapr API 时应 尽量满足可移植性 的诉求,API 设计时会偏 功能最小集
  2. 为了提供最大限度的可移植性,设计时往往会倾向于从功能最小集出发,如下图所示:

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第30张图片

  1. 出现 功能缺失:功能最小集合意味着 Dapr API 只定义基本功能,自然会导致缺乏各种高级特性,落地时会遇到无法满足应用需求的情况;
  2. 进行自定义扩展:为了满足需求,使用请求级别的 metadata 进行自定义扩展,提供 Dapr API 没有定义的功能;

metadata 自定义扩展

  • 优点:满足功能需求,metadata 的使用扩展了功能,使得底层组件的能力得以释放;
  • 缺点:严重破坏可移植性,自定义扩展越多,在迁移到其他组件时可能丢失的功能就越多,可移植性就越差;

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第31张图片
从图上看,当从可移植性为出发点进行 API 设计时,由于功能缺失迫使引入 metadata 进行自定义扩展,在解决功能问题的同时,造成了可移植性的严重破坏。从而偏离了我们的初衷,也造成整个 API 设计和落地打磨的流程无法形成闭环,无法建立良性循环。

Dapr 在阿里内部落地时遭遇的重大挑战

阿里是 Dapr 最早期的用户之一,在阿里内部落地 Dapr 时,遭遇了重大挑战:之前有的功能现在都要有!

这是来自业务团队的普遍需求,其背景是落地时遇到的三个现状:

  1. 十余年打磨下来,阿里内部中间件各种五花八门的功能都有。
  2. 社区开源版本 / 其他云平台提供的产品往往没有这些功能。
  3. Dapr 在内部落地时功能方面的 GAP 非常大。

其结果就是在落地时不得不引入 request 级别 metadata —— 这在短期内满足了功能需求,但从长期考虑损害了可移植性。

反思:左右为难,何去何从?

回顾前面我们谈及的 Dapr API 定义的核心挑战:功能丰富性和组件支持度难于兼顾。因此,在 Dapr API 定义和标准化过程中,在功能丰富性和组件支持度(可移植性)上如何取舍,就成为必须慎重考虑和严谨对待的关键问题。

实践为先:在落地中探索打磨

空想无益,实践为先,让我们以目前 state API 为例分析 Dapr 在 API 定义和标准化上的一些实践,让我们对此有更加深刻的理解。

换角度看问题:组件提供的能力不平齐

首先,让我们换一个角度看待这个问题,功能丰富性和组件支持度难于兼顾的核心在于组件提供的能力不是平齐的。

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第32张图片

如上图所示,不同组件所能提供的能力是不同的,因此他们能够支持的 API 高级特性也有所不同:有些组件支持的特性多一些,有些组件支持的特性少一些。体现在上图的左侧,当我们把这些组件的能力都罗列出来时,得到的时一个高低不平的图形。

最小功能集和最大功能集对应了这个图形中的最低处和最高处(简单起见我们假定最小功能集和最大功能集都刚好有组件可以直接对应),而 Dapr API 的设计关键就在于如何权衡功能丰富性和组件支持度,最终选择一个合适的功能集合。

解决思路一:Dapr Runtime 弥补组件缺失能力

还记得我们前面详细看过的 GetBulkState() 方法的实现代码吗?为了弥补部分组件无法支持批量操作的问题,Dapr 采用的方式是在 Dapr Runtime 中通过多次调用来模拟出批量操作的功能:

func (a *api) GetBulkState(ctx context.Context, in *runtimev1pb.GetBulkStateRequest) (*runtimev1pb.GetBulkStateResponse, error) {
    // try bulk get first
    bulkGet, responses, err := store.BulkGet(reqs)

    // if store supports bulk get
    if bulkGet {
        return bulkResp, nil
    }
    
    // if store doesn't support bulk get, fallback to call get() method one by one
    limiter := concurrency.NewLimiter(int(in.Parallelism))
    n := len(reqs)
    for i := 0; i < n; i++ {
        fn := func(param interface{}) {
            req := param.(*state.GetRequest)
            r, err := store.Get(req)
            item := &runtimev1pb.BulkStateItem{
                ......
            }
        }
        limiter.Execute(fn, &reqs[i])
    }
    limiter.Wait()
    ......
}

用"让子弹飞"的话来解释这段代码的功能,就是这个画风:我给了他(组件)一把手枪 (部署了 Dapr Sidecar),他要是体面(支持批量操作),你就让他体面(调用组件的批量方法);他要是不体面(不支持批量操作),你就帮他体面(多次调用单个操作的方法来模拟批量操作)。

前面我们在介绍 Dapr 和 ServiceMesh 的 本质差异 时强调过:Dapr 的工作模式是 能力抽象,Sidecar 原则上对应用只暴露抽象之后的分布式能力,屏蔽了底层具体的实现和通讯协议,不做 “转发” 而是提供 “能力” ,Sidecar 扮演的是运行时(“Runtime”)的角色

所谓 “组件能力缺失”,是指底层组件原生无法提供 API 定义的功能,如上面 State API 中定义的批量操作,现实中就是有不少 Dapr State 的组件原生不支持批量操作。而 Dapr 的工作模式使得 Dapr 有机会对组件缺失的能力进行弥补,请牢记:Dapr 的 Sidecar 是 Runtime, 而不是 Proxy
死生之地不可不察:论 API 标准化对 Dapr 的重要性_第33张图片

如上图右侧所示,Dapr Sidecar 对批量操作的实现进行了 “干预”:当发现底层组件不支持批量操作时,Dapr Runtime 会改用多次调用组件的单个操作方法的方式来模拟批量操作,从而在功能上实现了 State API 定义中的批量操作。

这是 Dapr 中解决功能缺失比较理想的方式,应优先采纳,当然这个代价是 Dapr 需要做更多的工作。但成效时非常明显的,如下图所示,在 Dapr 进行功能补齐之后,左侧组件功能不对齐的问题会得以缓解,相关的这些功能也就可以放心的纳入 Dapr API 的范围而无需担心组件支持度。

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第34张图片

解决思路二:Dapr Component 弥补组件缺失能力

思路和前面是一致的,功能出现缺失时由 Dapr Sidecar 来进行补救。但差异在于进行补救的地方的不是 Dapr 的通用代码,而是 Dapr 中该组件对应的组件实现代码,因此这个补救只能在当前组件中生效,无法复用到其他组件。

我们以 并发支持 为例,Dapr State API 通过 etag 的方式定义了乐观锁的功能,但是在使用 Redis 实现 State API 时就会遇到这样一个问题:Redis 原生是不支持 etag (或者叫做 version)的。而且这个功能缺失还无法在 Dapr 中通过通用的方式(如前面通过多次调用单个操作的方式来模拟批量操作)来进行弥补。为了支持 etag,Dapr 中的 Redis state component 在代码实现中就采用了 hashmap 类型的 value,通过 data 和 version 两个 hashmap key 来实现 etag 的功能

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第35张图片

而为保证对 data 和 version 这两个 hashmap 值操作的原子性,引入了 LUA 脚本:

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第36张图片
类似的实现在 Dapr 各组件的代码中有不少,这种方式也可以很好的弥补功能缺失。

解决思路三:Dapr 无法弥补,但可以模糊处理

State API 中的 saveState() 方法,请求中的 consistency 字段用于数据一致性,当组件支持多副本时,consistency 字段将用于指定对数据一致性的要求,其取值有两种:eventual:(最终一致性)和 strong:(强一致性)

死生之地不可不察:论 API 标准化对 Dapr 的重要性_第37张图片

下面是支持强一致性的 Redis state 实现代码:

func (r *StateStore) setValue(req *state.SetRequest) error {
    ......
    if req.Options.Consistency == state.Strong && r.replicas > 0 {
        _, err = r.client.Do(r.ctx, "WAIT", r.replicas, 1000).Result()
        if err != nil {
            return fmt.Errorf("Redis waiting for %v replicas to acknowledge write, err: %s", r.replicas, err.Error())
        }
    }
    ......
}

如果组件不支持强一致性,或者当前组件并没有配置集群不存在多副本,则可以忽略 consistency 参数。即使请求中的 consistency 字段明确要求强一致性,在不能实现或者无需实现时,可以简单忽略该参数而无需报错。

类似的,超时的实现在 State API 中是通过名为 ttlInSeconds 的 metadata 来实现。如果组件不支持超时,则可以简单的忽略该 metadata 。

解决思路四:无法弥补又不能模糊处理

有些功能的缺失,是前面三种解决方式都无法解决的,例如前面提到的 State API 中的事务支持

  • 刚需:从需求上说,这个功能必须有,比如 Dapr Secret Store 就严重依赖 State API 的事务方法;
  • 硬伤:从实现上说,的确很多组件原生是不支持事务;
  • 弥补:Dapr 目前无力弥补;
  • 模糊:不能模糊处理,必须明确支持或者不支持,在有事务需求的场景下必须选择支持事务的组件不然无法保证多个操作的事务性;

Dapr State API 目前的做法是按照是否支持事务来区分 state components :用户如果需要事务支持,必须选择支持事务的组件。而需要事务支持时,可移植性的范围被限制为支持事务的组件列表。

这个解决方案的缺陷在于会对可移植性造成灾难性后果:如上面 State API,一旦要求支持事务,则只有约 40% 的组件可以支持。因此必须严格限制使用:只能为个别关键特性开特例,不能滥用,不然使用者选择组件时会非常痛苦。

更大挑战:比 State API 复杂的多的 Configuration API

虽然我们前面详细列出了在 State API 中为了实现各种高级特性而进行的各种解决方式,但 State API 其实本质上还是一个比较简单的 API,高级特性也屈指可数。

而 Dapr API 中还有其他比 State API 要复杂的多的 API,如 Dapr 社区在今年开始深入讨论和尝试加入的 Configuration API。由于目前各种配置产品的差异性实在太大,甚至连最基本的配置模型都存在巨大差异,导致 Configuration API 在制定时遇到非常大的阻力。简而言之,和 State API 相比, Configuration API 要复杂 10 倍。

对此感兴趣的同学请浏览:https://github.com/dapr/dapr/issues/2988

路阻且长:但行好事莫问前程

虽然 Dapr 还很稚嫩,虽然多运行时(Mecha)的理论还在早期实践的过程中,但我坚信 Dapr 作为多运行时理论的第一个实践项目是符合云原生的大方向,Dapr 能为云原生应用带来巨大的价值。而从产品形态来说,目前 Dapr 是走在云原生社区的前面,作为 Dapr 的早期实践者,可以很骄傲的说:我们是云原生的开拓者,我们正在创造云原生新的历史。

Dapr API 是 Dapr 成败的关键之一,从 云原生发展的角度未来也需要这么一个通用的分布式能力的 API 标准,诚然目前的 Dapr API 需要在不断实践中补充和完善,而且这个过程注定会很艰难,就像前面这个迟迟未能顺产的 Configuration API。

欢迎更多的公司和个人参与到 Dapr 项目。

Dapr github 地址:https://github.com/dapr

你可能感兴趣的:(Dapr,分布式运行时,面向能力编程,Sidecar,Dapr,云原生,分布式能力)