本文要点
- 生成工具开始时必须有一个定义好的范围,而且能够为利益相关者带来真正的价值;
- 有时候,单是一个基于模板或脚手架的生成器是不够的;
- 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 据此可以知道如何处理它们。所有的逻辑服务和组件都封装到模块中。
当决定抽象化系统并生成代码时,有必要确定下 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 操作的库。这是一个长期项目,有一个有帮助的开发者及贡献者社区。
Angular 组件
操作抽象 JavaScript 树是 Angular 架构的核心。为了实现这项功能而又不引入许多依赖,避免复杂度的提升,保持代码的健壮性,我们选择了 Recast 以及 AST-Types。
Recast 是一种读取和写入 JavaScript 代码的高级 API,而 AST-Types 是一种解析和构建 JavaScript 抽象树的底层 API。
在图 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 中,构造函数领域类通过一个具体的访问者查找模板的默认构造函数,然后插入功能代码(变量声明、初始化、引用,等等)。下面的例子中有一个访问者函数。
可以想象,一个有许多模板和组件的大型应用程序会导致性能衰退。那些问题会使 Node JS 虚拟机退化,因为抽象树遍历的处理成本和内存利用率很高。“自然演进(natural evolution)”会使用一种更优雅更流行的 Angular Tree Domain(ATD)替换访问者函数。
从根本上讲,ATD 是一个架构概念,是为了隔离复杂性,使 Angular 组件(包括 HTML 模板和 JS 组件)Fluent 的功能对生成器透明,如下图所示。
图 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 组件构造函数调用每个 fluent 方法构建组件的每个部分。Angular 模块编排处理器会向 Angular 组件构建器的入口发送一条命令。如上图所示,在 AngularComponentBuilder 的构造函数中是一个有顺序的构建器调用序列。每个构建器各负其责,保证高内聚和低耦合。
因此,每个构建器都有自己的抽象树片段,主 AST 模型可以在任何时间通过根构建器构建。最终的 AST 成为语义模型。这种表示法意味着 AST 模型结果是逐步构建起来的。
稍后,我们还会稍微详细地介绍下,以便更好地理解这个概念,但是现在,我们深入介绍 Angular 组件的构建,并逐语句看一下 ClassBuilder。
在图 9 中,ClassBuilder 引用了一个负责调用 AST 函数的类,用于创建和解析抽象树。借助桥接模式和组合模式,架构中的所有构建器都把底层实现委托给了语法树类,保证 API fluent。
让我们看下 addRequire() 方法,显然:它创建了一个 RequireSyntaxTree 类引用,并添加到 ClassSyntaxTree 引用。然后,它返回构建器的自引用,保证它 fluent。任何时候,就像前面提到的那样,AST 模型都可以还原,因为所有构建器都持有到其 SyntaxTree 类的引用。
所有 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
评论