Angular 应用程序生成器架构概述

阅读数:576 2018 年 6 月 12 日

话题:JavaScript语言 & 开发

本文要点

  • 生成工具开始时必须有一个定义好的范围,而且能够为利益相关者带来真正的价值;
  • 有时候,单是一个基于模板或脚手架的生成器是不够的;
  • Angular 平台的架构至少必须提供模板创建(HTML)、组件解析(JavaScript)、Angular 元数据及应用程序构建工具的解决方案;
  • 其实现可以结合源代码生成模板解析;
  • Javascript 解析可以划分为不同的粒度级别,从而隔离复杂性。

软件自动化是一个有意思的软件开发主题。我第一次接触这类应用程序是在 2004 年见到 Middlegen:这是一款数据库驱动的通用的免费代码生成工具。我记得,我用它完成过下至 CMP 2.0 层生成、上至 JSP/Struts Web 页面构建的工作。我那会还没意识到,有些生成工具可以在一眨眼间完成大量的工作。

从那时开始,过了几年,“软件自动化”的主题依然存在于社区中,专家、开发人员、架构师对其有效性持有不同的看法。一方面,它减少了软件构建的时间。所有重复性的工作都可以快速地交付,与此同时,团队可以专注于业务需求的开发。另一方面,如果没有定义好的范围就决定编写一个软件生成工具,那会很困难,而且有危险。

实际上上,在做决定之前,要记住,代码生成显然既有优点,也有不足,但 Angular 呢?使用什么方法生成 Angular 的源代码最好:模板还是 AST 处理?

本文将深入研究技术,基于一种 DSL 机制实现所生成代码的一致性和可维护性,把 Angular 源代码生成带到一个新的水平。

为什么要自动化?

乍一看,软件自动化意味着你可以通过标准化重复性的工作(例如 CRUD)来节省时间。但是,这里有一个有趣的问题:我们为什么要使用一个通用的软件构建器?

这是没有必要的。我们经常会设法把事情做得更具一般性,因为逻辑、抽象和模式实际上是人类的概念,而缺少经验会导致你做出一些错误的决定。例如,在预见未阐明的决定、未知的问题甚或是解决现在还不存在的未来问题时,有些开发人员通常会选择一种通用的方法

其他人会认为,设法抽象需求,编写几个类的通用代码应该比从软件生成工具的角度来思考要简单;实际上,就是强烈反对软件自动化。但是,事实上,如果他们已经知道应用程序的范围,并且对需求有一个深入的了解,有经验的开发人员就会充分利用软件自动化。当然,我们没有说那是一项简单的工作,但我们非常确定,那是可行的。例如,有个团队正在把一个遗留应用程序迁移到一项新技术,他们可能会有扎实的知识和经验来判断软件自动化是否合适。

为了利用软件生成工具,必须要明确可以自动化的标准,和利益相关者一起确定范围界限,并把它们转换成真正的产品价值。从根本上讲,敏捷思维是构建软件自动化的关键因素。

为什么不能仅仅使用模板?

要回答这个问题,我们要反问:为什么不使用源代码生成?在代码模板和代码生成之间做选择时涉及几个步骤,这可以让我们明白哪条路才是正确的:模板、生成,还是二者兼而有之。

大多数时候,如果我们决定简化源代码生成,遵循一个非常严格的标准,使用一种实用的方式开发一个应用程序,那么,像 Angular CLI 和 Yeoman 这样的工具就非常有用。Angular CLI 在做脚手架时非常有用。同时,Yeoman 生成器可以为 Angular 提供非常有价值的生成器,并且提供了很大的灵活性,因为你可以自己编写生成器来满足你的需求。不管是 Yeoman,还是 Angular CLI,都通过它们的生态系统丰富了软件开发:提供大量定义好的模板,用于构建最初的软件框架。

另一方面,仅仅使用模板会很困难。有时候,标准化原则可以转换成几个模板,可以用于许多场景。但是,当要设法自动化有许多变化、布局和字段差别很大的表单时,那就不适用了,这种情况下,会产生无数的模板组合。这只会让人头疼,并引入长期的问题,因为它降低了可维护性,导致了技术债务。有理由认为,软件开发应该以良好的原则为基础,如 KISS、YAGNI、模式等。

如果可以混合模板和源代码生成,而不是根据开发人员的偏好选择一种片面的模型,那会很棒。

理解 Angular 的基本架构

首先,我们必须理解架构的工作原理,抽取出概念,归类到两个关键部分:模板代码和生成代码。

Angular 采用了基于组件的架构,其基本结构是 HTML 模板和 TypeScript/JavaScript 组件。HTML 模板是使用标记语言设计的,有属性,有事件,而组件负责处理这些事件。这些组件是通过元数据管理的,Angular 据此可以知道如何处理它们。所有的逻辑服务和组件都封装到模块中。



图 1、Angular 的架构

当决定抽象化系统并生成代码时,有必要确定下 Angular 架构中哪些部分最重要。这里列举下三个关键的因素。

首先,模板是 HTML 结构的数据,既可以作为 HTML 实体解析、操作和渲染,也可以作为基于占位符的模板化文件来处理。组件的处理方式类似:解析抽象树或者处理 JavaScript 模板文件。

其次,对于 TypeScript 代码,有几个问题:为什么我们不能遍历 TypeScript 抽象树来生成源代码?我们可以,但是,那会消耗额外的内存,需要更多的处理,因为那总是需要把 TypeScript 编译成 JavaScript 代码。另外一个大问题是抽象树处理。可以遍历 TypeScript 树,但我们的项目期限要求我们用一个强大的库 /API 通过一种简单的方式遍历和构建 JavaScript 树。

最后,可能还有一些类似元数据、指令和变量注入器这样的更为细化的事项。时不时地模板化这些东西会很痛苦,而且仅通过模板来维护这些逻辑代码会很困难。



图 2、webpack bundle 中的 Angular 依赖注入

如图 2 所示,这是一个典型的 Angular 5 应用程序。首先,TypeScript 代码被编译,然后生成的 JavaScript 代码被打包进一个 webpack 文件。Angular 提供了一组函数,可以将 TypeScript 元数据转换成有意义的 JavaScript 代码:

  • __decorate() 函数负责封装整个 Angular 组件;
  • Component() 函数处理 HTML 模板和 CSS。它是作为根 / 其他模块的占位符,也就是架构中的“选择器(selector)”;
  • __metadata(“design:paramtypes”, …) 函数会把一些依赖项注入到相应的构造器参数。

上述三点非常重要,让你在生成源代码时可以通过处理 JavaScript 抽象树避免一些麻烦。在继续之前,可以通过下面的方法做决定:

变量、参数、逻辑控制越多,就越适合通过树处理。否则,通过模板。

定义一个源代码生成平台

为了创建一个可靠的 Angular 模板和组件源代码生成架构,合理的做法是选择社区支持并且在不断发展的工具。为了从 Angular 组件生成代码,需要完成 HTML、JavaScript 树和模板处理。因此,我们选择了几个库来解决我们的代码生成。

Angular 模板

为了处理 HTML 源代码操作,最好是使用一个可以遍历 DOM 树并能生成简洁、安全的函数代码的库。后来,我们选择了 Cheerio 库。Cheerio 是一个基于 JQuery、用于 HTML 操作的库。这是一个长期项目,有一个有帮助的开发者及贡献者社区。



图 3、Cheerio 操作样例代码

Angular 组件

操作抽象 JavaScript 树是 Angular 架构的核心。为了实现这项功能而又不引入许多依赖,避免复杂度的提升,保持代码的健壮性,我们选择了 Recast 以及 AST-Types。

Recast 是一种读取和写入 JavaScript 代码的高级 API,而 AST-Types 是一种解析和构建 JavaScript 抽象树的底层 API。



图 4、Recast parse 和 print 高级用法

在图 4 中我们可以看到,从代码构建树非常简单,反之亦然;AST-Types 可以读取 JavaScript 树的特定节点。Recast 可以可以辅助读 / 写整个应用程序,而 AST-Types 可以用于操作小部分代码。



图 5、AST-Types 使用访问者模式遍历一个函数的返回语句

模板处理

至于模板处理,我们选择了 Yeoman,因为它简单。该工具会自动化构建过程以及它们的依赖关系,而且主要是面向 Web 应用程序。该工具为项目静态部分和生成部分的整合提供了便利,我们可以扩展项目构建过程而不增加复杂度。

选择一个 Angular 入门工具包对模板加以利用

我们选择了著名的Angular Webpack Starter作为我们的模板样板。该项目的创建者做了一项了不起的工作,Angular Webpack Starter 有一个初始设置,整合了最好的库。我们的生成器的基础应用模板就是使用这个入门工具包构建的,那让我们的工作变得更容易,让我们可以把更多的时间花在更复杂的问题上。

遵照最佳实践编写 DSL 代码

最初编写应用生成器代码时并不简单。在我们最初的场景中,最小可行产品(MVP)包含几个 JavaScript 模板类,这些类是通过 Yeoman 模板编排的。这些 JavaScript 模板类是通过领域类来处理的,为了找出抽象树中的引用并插入代码片段,它们实现了一些访问者函数。



图 6、使用 Recast 以及 AST-Types 处理的组件模板

例如,在图 6 中,构造函数领域类通过一个具体的访问者查找模板的默认构造函数,然后插入功能代码(变量声明、初始化、引用,等等)。下面的例子中有一个访问者函数。



图 7、用于构造函数声明的 AST-Type 访问者

可以想象,一个有许多模板和组件的大型应用程序会导致性能衰退。那些问题会使 Node JS 虚拟机退化,因为抽象树遍历的处理成本和内存利用率很高。“自然演进(natural evolution)”会使用一种更优雅更流行的 Angular Tree Domain(ATD)替换访问者函数。

从根本上讲,ATD 是一个架构概念,是为了隔离复杂性,使 Angular 组件(包括 HTML 模板和 JS 组件)Fluent 的功能对生成器透明,如下图所示。



图 7、Angular 源代码架构

图 7 展示了 Angular 应用程序生成器的总体架构。

Angular 应用程序生成器

这是应用程序入口,负责读取元数据并转换成技术数据供 ATD 使用。我们不会详细介绍应用程序的这个部分,而只是大概地介绍一下。它包含如下组件:

  • 元数据 XML/JSON 输入——描述系统模块高级信息的文件;
  • 应用程序规则转换器——负责读取具有有意义的应用程序规则的 XML 元数据,并在 Angular 模块的内存数据库中对它们进行转换;
  • 模块数据——包含模块的内存数据库。这些模块是系统模块对象,描述了它们的表单域及其组件和依赖。

Angular Tree Domain(ATD)

这是架构中最重要的组件。ATD 是系统域,包含 Angular 应用程序构造的核心 DSL 构建器。这些 DSL 由 Angular 模块编排器处理和编排。

Angular 模块编排器

这个模块是一个简单的 Yeoman 生成器,负责连接不同的组件,并编排它们的执行。有一组排好序的“处理器(Processor)”会在一个责任链处理器实现中执行。这些处理器是一些任务,负责处理应用程序的每个部分,如 Angular 组件和模板、模型、SASS 文件和菜单系统更新。我们不会详细介绍这个模块,但是,我们会介绍处理器使用的两个最重要的组件:

  • Angular 模板——负责生成 HTML 源代码的对象 Fluent API;
  • Angular 组件——负责生成 JavaScript 源代码的对象 Fluent API。

Fluent Angular Component API 分成三个基本组成部分,下面我们会详细介绍。

DSL 构建器

DSL 构建器是一组高级构建器(以DSL 模式为基础),处理 JavaScript 组件构造的每一个重要部分。从这个角度讲,大多数 Angular 开发人员都应该使用这种高级实现进行开发,对于生成器而言,这有助于创建新的组件。

构建器的粒度会随着其在树解析中的职责而增加。例如,Angular 组件构建器是根粒度,因为它是入口,通过构建器组合实现整个代码。类构建器的粒度就低一些,它不知道 Angular 组件构建器的存在,因为后者在树顶。



图 8、Angular 组件构建器

如图 8 所示,Angular 组件构造函数调用每个 fluent 方法构建组件的每个部分。Angular 模块编排处理器会向 Angular 组件构建器的入口发送一条命令。如上图所示,在 AngularComponentBuilder 的构造函数中是一个有顺序的构建器调用序列。每个构建器各负其责,保证高内聚和低耦合。

因此,每个构建器都有自己的抽象树片段,主 AST 模型可以在任何时间通过根构建器构建。最终的 AST 成为语义模型。这种表示法意味着 AST 模型结果是逐步构建起来的。

稍后,我们还会稍微详细地介绍下,以便更好地理解这个概念,但是现在,我们深入介绍 Angular 组件的构建,并逐语句看一下 ClassBuilder。



图 9、类构建器

在图 9 中,ClassBuilder 引用了一个负责调用 AST 函数的类,用于创建和解析抽象树。借助桥接模式和组合模式,架构中的所有构建器都把底层实现委托给了语法树类,保证 API fluent。

让我们看下 addRequire() 方法,显然:它创建了一个 RequireSyntaxTree 类引用,并添加到 ClassSyntaxTree 引用。然后,它返回构建器的自引用,保证它 fluent。任何时候,就像前面提到的那样,AST 模型都可以还原,因为所有构建器都持有到其 SyntaxTree 类的引用。



图 10、语法树抽象类

所有 SyntaxTree 类负责处理树解析的底层代码。随着项目的不断重构,大部分树节点解析都委托给了公共类(如图 11 所示)。它帮助这些类保证代码的简洁和功能的强大及有意义。下面的代码就展示了这种情况。



图 11、getAst() 实现,添加一个 REST 路径参数



图 12、RequireSyntax 类——getAst() 返回树表达式

如图 12 所示,组合这些帮助解析和生成树的功能非常有用。在这个例子中,类“utilsCommon”有一些小功能用于创建属性、变量和数组。

至于类,我们可以把它们描述为 AST-Type 共享小函数的底层实现,如图 13 所示:



图 13、两个由它们自己和 SyntaxTree 类共享的函数

把 Fluent API 分割到不同的层,实现低耦合,有助于我们进行无数的单元测试,保证整个 ATD 的一致性。当然,所有的构建器、SyntaxTree、公共 / 工具类都有单元测试。对于任何类型的 JavaScript 应用程序而言,像 mocha、expect.js 和 assert 这样的库和工具都是一个可靠的组合。在本文的末尾,我们将在图 14 中展示一个简单的单元测试场景,测试“utilsCommon”函数。



图 14、函数“createVariableRequire”的简单测试用例

为什么要自动化?

最后,我们再回到本文开头提出的问题:为什么要自动化?

实际上,这不是个容易做出的决定。对于许多开发人员而言,“代码生成”这个主题看上去让人兴奋,但对于管理人员和 CTO,我们认为情况并非如此。我们始终要记住两点:这种方法的真正好处是什么以及如何汇聚成最终的产品价值?在决定生成源代码时,我们必须思考和争论其优缺点,但是有一点很清楚:团队的成熟度和专业知识有所不同。

关于作者

Jonatas Wingeter Rodrigues 是小型 IT 咨询公司 IS Tecnologia 的一名高级软件顾问。作为一名现场顾问,Jonatas 为巴西南部一个大型商场服务。他从十几岁就开始接触编程。自 2002 年开始,他大部分时间都在从事软件开发、架构定义、团队指导及领导小型团队,有国内项目,也有国际项目。在业余时间,他喜欢和家人一起亲近自然,学习新语言,如德语和法语。感兴趣的读者可以在Linkedin上和他联系。

Luciano Augusto Yamane 是 IS Tecnologia 的一名高级软件工程师。作为一名现场顾问,他和他的同事 Jonatas 在不同的技术领域展开合作,如 Android、JavaEE、Angular 和 NodeJS。他有不少于 10 年的软件开发经验。在业余时间,他喜欢打网球。感兴趣的读者可以在Linkedin上和他联系

查看英文原文:Angular Application Generator - an Architecture Overview