元数据驱动设计 —— 为动态移动应用创建 Web API

邵思华

阅读数:4445 2015 年 9 月 17 日

话题:REST移动DevOps语言 & 开发架构

时间回到多年之前(当时我的头发还没这么稀疏),Google 在 4 月 1 日这一天发布了 Gmail,这不由得令许多人怀疑这个产品是否只是 Google 精心炮制的一个玩笑。但谁又能够去指责他们的怀疑呢?毕竟整个互联网行业还不够成熟,它仍然停留在青春期阶段。甚至 Web API 服务器在当时也是一门新生的技术。在当时,许多不同种类的机器之间的远程调用仍然时常会通过由服务器生成的过程桩进行“握手”的方式以实现通信(例如 CORBA)。

差不多在同一时间,软件架构师 Kevin Perera 在他当时所参与的项目中找出了通信层的一种模式。为了简化这种风格的架构以及具体实现中的构建过程,他提出了一种全新的设计方法作为此问题的解决方案1。他将其称为“元数据驱动设计与开发”,这种方法能够将设计与开发整合起来,并通过一种客户端 - 服务器的软件开发范式将两者统一起来。他通过一个示例表明,通过几行元数据,就能够描述某个服务以及它所暴露的方法。并且它的抽象层次比起接口定义语言(IDL)更高,因此可以使用同样一套元数据去驱动整个分布式系统的构建、交互以及遍历。如果开发者需要为服务加入一个新的方法,所需的工作只是加入几行新的元数据,以及必要的少部分代码更新而已。服务端只需使用一些简单的功能与数据结构,而不必对它的接口进行任何后续的变动,因为元数据能够有效地承担交互的契约。作为元数据设计的早期追随者,Perera 的努力让整个软件社区的目光投向某个简单却新颖的思想:只需将软件的设计进行更进一步的抽象,我们就能够创建出这样一种解决方案,它能够允许设计上的各种改进同时进行计划与实现。虽然这个方案是 10 年之前所提出的,但它的优点却延伸至今。

那么,元数据驱动设计究竟是什么?简单地说,可以将其总结为一种软件设计与实现的方式,而元数据可以构成并整合开发过程的这两个阶段。在我之前所发表的一篇文章中2,我曾经描述了如何使用元数据驱动设计(MDD)方式以设计出一种引擎,让它从某个 API 服务器中获取数据。现在,我们将从那一场景中提取出相同的核心思想,并表明 MDD 是怎样帮助我们为某个 API 服务器打造一个现代化的架构,尤其是用于 iOS 移动应用的服务端架构的。

虽然我在移动应用开发领域称不上专家,但也多少有所涉猎。在创建基于 iOS 和 Android 这些平台上的应用时,我特别留意了在原生应用开发过程中额外增加的一部分时间,尤其是与开发桌面应用相比所增加的时间。除了应用本身的用户交互性要进行大量的测试之外,还有很大一部分时间是在使用一些更为复杂的框架(例如 Cocoa)时对应用的导航控制流进行组织的时间。当然,还有将应用提交审核的等待时间,以及根据应用商店管理者的要求和用户的需求而对应用进行后续修改及调整所需的时间。(仅举一例,我刚刚才知道在应用中不能够将某个特性命名为“魔法 8 球”(Magic 8-Ball),因为美泰公司(全球最大的玩具公司)会认为你侵犯了他们的商标。这种事谁会知道啊?!)。如果将这些时间与精力都计算在内,那么结果就很明显了:为了简化这种应用的维护与更新,你必须投入巨大的精力。在几个小时的开发工作之后,我就意识到,如果有某种方式能够动态地更新应用的数据与行为,将带来很大的益处。而如果我能够对应用进行配置,让它的行为由元数据库所驱动,那我就能够减少在进行各种变更时所必需的投入。事实上,客户端代码能够向某个 API 服务器请求这些元数据,通过在这些返回的元数据中实现变更,我就能够避免将更新的应用提交给对应的应用商店的过程了。更妙的是,我同样能够使用 MDD 的方法设计 API 的服务端,因此客户端与服务器就能够更紧密地整合在一起了。

(点击放大图像)

当然,有些专业人士可能会对此提出反对意见,认为这种做法会在客户端与服务器之间造成紧耦合。毕竟,在两者之间保持一种松散的分离性通常来说是比较理想的情况,有大量的依据能够支持他们的观点。仅举一例,如果两者能够保持一种清晰的划分,那么客户端与服务端团队在开发时就能够充分体现出自治性与灵活性,而紧密的耦合会让这种自由性丧失殆尽。此外,对许多开发者来说,这种方式意味着一种有状态的交互,这显然违反了那些实现与利用 RESTful API 的开发者所寻求的无状态会话的目标。更重要的是,在某些场景中我们可能会需要对架构进行某些调整,例如切换服务端的平台。这种情况下,客户端与服务器的紧耦合会给开发者带来更沉重的负担。因此,在许多情况下,这种设计方法确实无法让项目设计者从中受益。

举例来说,在一些竞争激烈的市场上(例如游戏业),任何一个流行的移动应用都少不了多个团队的共同努力,以及不断的新功能改进。这种不稳定的行业需要极大的灵活性,以适应快速的发展节奏以及这一行业中始终伴随的各种惊奇。显然,在这种类型的场景中,这种设计方式并不适用于这样的项目。那么,尽管有着诸多的不足之处,我们为什么还要提出这样一种决策呢?我们要记得,当今仍有许多应用的期望是完全不同的。这些项目的范围相对较小,并且它在稳定性方面的需求远远胜于可能性较低的新特性的需求,对于这些项目来说,这种设计方法就能够体现出极高的价值。举例来说,由某个小型的、专注的团队所开发的企业移动应用就能够发挥这种策略的特长,尤其在应用的目标群体只限于数量有限的用户的情况下。企业项目在这方面的顾虑较少,因为它们通常不会遇到商业级移动项目所面临的大量不稳定因素。在这种情况下,这种设计方法的正面因素就很可能盖过它的负面因素。另外还有一点很重要的提示:虽然这种方法提倡客户端与服务器之间的紧耦合,但它在我们所提到的企业级项目中并不强制使用有状态的会话。

那么,我们如何实现这一目标呢?假设我们的目标是创建一个 API 服务,它可用于两个目的。首先,它将提供一个简单的接口而不需要进行任何调整,哪怕我们最终会为客户提供一些更多的功能。其次,它的第二个目标是为某个简单的 iOS 应用提供数据以及行为指导,在客户端会通过 HATEOAS(超媒体即应用状态引擎)的某种变体实现。与 Roy Fielding 在博士论文3中的建议类似,我们的目标也是实现“服务端将返回实体的表现作为选项,让用户端选择具体的行为,或是由用户对这些实现表现进行操作,以用户的选择或操作驱动整个应用。”(因此,我们的客户将从服务端的响应所包含的元数据中的选项进行选择。)一般来说,这种应用只有一些简单的目标:让用户能够在几个屏幕之间进行导航、请求服务端进行某些运算、在服务端启动某个特定的批量作业(或是在另一个远程系统中)、上传与下载文本(可能会与设备的本地文件系统进行交互)、并查看所下载文本的内容。应用的一般性导航与行为都由 API 服务所提供的元数据所控制(希望我们不会经常需要重建或重新提交与部署应用!)。在这个示例中,我们将选择.NET 平台实现我们的 API 服务。具体来说,我们将通过 Visual Studio 中的 ASP.NET MVC 4 框架所提供的标准模板创建一个 Web Service。不过,在开始进行 Web Service 的编码之前,我们需要采用 MDD,并决定应用所需的元数据。根据我们的简单需求,我们需要类似于下表中的元数据:

(点击放大图像)

我们接下来会将元数据加载到 Web Service 所用的缓存中,当从 iOS 应用中获取到请求时就可以使用这些数据了。为了遵照 HTTP 规范4,ApiController 中的方法将根据 HTTP 请求的类型作出相应的行为,具体地说,我们会在 RESTful API 中实现 GET 与 POST 方法的处理器。与 Perera 的示例类似,我们希望为这两个处理器创建一种通用的可扩展函数签名,使它们能够处理由不同的行为所产生的不同请求。现在,我们已经将全部因素都考虑周全了,可以编写处理请求的响应代码了:

(点击放大图像)

通过将接口简化为一个 controller 中的两个方法,我们就能够让客户端与服务器的所有交互操作的通信流合理化。此外,我们现在还可以对元数据中的某些属性进行修改,以动态地更新服务的可用功能。对于某个新的 POST 行为,我们可以通过在元数据中加入一个新的行,并在特定的文件夹中放置一个新的 DLL 文件,就能够提供一个新的方法。更重要的是,这个简单的应用无需我们重新构建与部署 Web Service!由于无需重新部署 Web Service(将其作为我们的通用网络基础设施的一部分),我们就能够收获一系列间接的益处。首先,我们可以确保客户端与服务器之间的通信机制出现开发者人为失误的风险非常小,一旦排除了这种可变因素,今后的调试工作都能够极大地简化。因此,我们的单元测试也可以专注于新的 DLL 中的新的、或是更新后的功能,因为我们对于 Web 方法进行回归测试(例如检测是否有哪个 Web 方法的签名出错了)所需的精力也大大降低了。其次,在将 Web Service 部署到生产环境方面,我们也能够节约大量的时间也精力。第三,通过将网络请求归结为几个 Web 方法,对这些方法的安全性的维护与审查方面所需的管理工作也减少了。最后,通过遵循与依附于这样一种 RESTful 的风格,我们也能够保障 Web Service 的可伸缩性。在到目前为止,我们只实现了整个架构中的服务端部分,但我们已完成了创建一个动态客户端所需的第一步工作。

那么具体怎样实现一个动态的客户端呢?正如我们之前所说,这个简单的 iOS 应用允许用户在几个屏幕之间进行导航。正如任何移动应用的开发一样,理想的方式是每个屏幕都能够以某种方式实现自定义(在 Android 平台上,自定义体现在一种不同的级别上,随着每种不同的设备及不同大小的屏幕出现,这种组合也在不断增加)。这种自定义性需要开发者的工作体现出一定的细致程度。我们以一个简单的示例进行说明,我们使用了一个寻常的 UITableView 屏幕,在其中简单地显示一个可滚动的、可互操作的多个行的列表。这些行通常会显示文本或图片,列表中的每个行(也就是单元格)的布局、字体、大小等等都可以进行自定义。一般来说会倾向于让这些行表现得更为标准化,让每一行都表现出一致的结构,使整个列表看起来显得更统一(也就是更有组织性)。不过,我们可能会在之后改变它们的某些表现。更重要的是,我们可能会改变这个列表中的某些内容!与众多现有的 iOS 项目一样,我们也可以简单地在实际代码中写下对这些行的外观所进行的改动……但这种方式的麻烦是需要进行重新部署与重新提交。

这个简单的 iOS 应用会以一个 UITableViewController 作为它的启动屏幕,我们将创建一个继承自 UITableViewController 的类,名为 “MainView”。通常来说,在应用的启动阶段,你可以在“viewDidLoad()”方法中找到视图中的这些特定行的显式声明。而在这个示例中,我们会选择一些不同的做法:我们将使用一个 NSURLConnectionDelegate,向服务端发起一个 GET 请求,将参数“name”设置为 “main”。所返回的响应结果是一个 JSON 格式的消息,其中提供了相关内容以及主屏幕(即启动屏幕)所应当显示的外观等描述性信息:

(点击放大图像)

请注意,我们并没有在元数据中包含所有可能的选项。比方说,我们就省略了用于启动批量作业的元数据选项。不过,我们今后可以随时将这些选项加回名单之中。当然,你应当始终为你的应用绑定一个默认的 JSON 配置,以应对网络诊断发现失去连接的状况。不过通常来说,这个返回的数据会为我们的主屏幕带来最新的配置信息。如果你还需要支持其它类型的移动应用(例如 Android),就有必要为应对该平台的特定显示需求提供不同的响应数据。如果你观察一下这个响应数据的示例,你就会发现在 UITableViewController 的某个实例中所定义的行,尤其是用于显示与交互的重要数值。其中的“enabled”这个属性是用于这个移动应用代码内部的逻辑的,它能够让我们通过对这个响应体进行简单的编辑,以控制某些选项的展示或移除(在以上示例中,当主屏幕中调用 “viewDidLoad()”方法时,“Advanced Settings”这一行将不会展示给应用的用户)。

不过,在某些情况下,不论是用于屏幕配置管理的响应体还是包含着额外功能的 DLL,对于文件系统的依赖或许不是一种理想的方式。举例来说,出于可能的安全性方面的问题,某些云提供商不建议在他们的 PaaS 平台中部署需要访问本地文件系统的 Web Service。在这种场合下,我们希望在不使用文件的前提下依然具备相同的功能,这一点可以通过使用数据库中的表实现。显然,我们能够方便地将屏幕配置的内容保存在数据库表中的某个 CLOB 列中。另一方面,动态功能的实现会为我们带来一些困扰。最安全的做法是提供一个转发 URL,它将指向一个受防火墙保护的网络中的某个不同的 Web Servide。除此之外,也存在一些其它的选择。我们在这种场合中仍然可以通过一个 CLOB 列实现我们的目标。根据你选择的平台的 JIT 能力(以及你是否愿意承担一定的风险),你或许能够对元数据表中的某一行所保存的代码直接进行编译与执行。在我们所选择的这个示例中,我们所选择的平台是.NET 环境。而在这一场合下,通用使用 System.CodeDom.Compiler 命名空间(具体来说是 CSharpCodeProvider 类)所提供的功能,我们完全有可能利用 JIT 编译的能力。只要这个动态功能不需要用到一整个项目中的所有类,我们就能够轻易地将一个 C# 类(或同等功效的代码块)内嵌在某个 CLOB 列中,并在运行时进行执行。这一切过程都无需使用文件系统。

不过,仅仅一个示例无法展现出 MDD 能够让我们实现及利用的全部潜在价值。即便看过这个示例,你可能也会认为以上场景只是一种多维配置文件的展示罢了……如果我们的设计就此打住,那么你的看法并没错。但是,我们还没有真正地利用 MDD 的优势。比方说,这个 Web Service 并不一定只能够用于 iOS 设备上,只要提供更多的元数据,就能够在使用完全相同的代码的前提下支持其它平台(Android、 Xamarin 等等)。通过遵循 Perera 的建议,我们还能够在元数据中加入方法签名与数据布局,让所有的构建块都依赖于它。如此一来,我们就能够确保服务端与客户端的代码都是自同样的源头同步而来。(显然,这种代码的普通性还有一个良好的副作用,它能够促使服务端开发团队与客户端开发团队之间进行更多的知识传递。)这种方法还可以进一步改进,即在客户端实现一个额外的功能,使它能够随时从服务端缓存中获取各种选项的最新配置。这个额外的层是创建整个解决方案的最终迭代的下一步:最终目标是在服务端与客户端双方实现直接的动态交互。正如我在之前一篇文章5中所写到的一样,我们可以反复地进行设计与开发,以实现将客户端和服务端真正地结合在一起的目标。只要两者之间所交换的属性相对较小,我们就能够以较少的精力实现客户端的智能化。而作为回应,我们能够让客户端集成并利用 Web Service 的方法与数据结构,而一切都发生在运行时。

关于作者

Aaron Kendall是一位居住在纽约的软件工程师,在企业数据系统的设计与实现方面具有近 20 年的经验。他刚开始是一位设备驱动程序的开发者,随后转为专业软件的开发者,在此过程中他表现出了对软件设计及架构方面的热情。他曾经在多个平台上通过多种语言创建了具有创新性的商业解决方案,以及许多作为自由职业者创建的软件项目,包括开源的软件包,以及游戏设计和移动应用。如果你想进一步了解他的工作,欢迎访问他的LinkedIn页面并阅读他的博客

参考

  1. Perera, Kevin S. (2004 年 1 月) 元数据驱动应用设计及开发,MSDN 开发者网络
  2. Kendall, Aaron (2015 年 4 月 13 日) 元数据驱动设计 —— 设计一套用于 API 数据检索的灵活引擎,InfoQ
  3. Fielding, Roy T. (2008 年 10 月 20 日) REST API 必须由超文本驱动
  4. W3C (2003 年 7 月 9 日) URI、可寻址性,以及 HTTP GET 和 POST 的使用, RFC 2616
  5. Kendall, Aaron (2015 年 2 月 19 日) 元数据驱动设计 —— 连接设计与开发的敏捷桥梁,InfoQ

查看英文原文:Metadata-Driven Design: Building Web APIs for Dynamic Mobile Apps