给Java程序员的Angular快速指南

太长不读版:

Spring + Angular 的全栈式开发,生产力高、入门难度低(此处省略一万字),是 Java 程序员扩展技术栈的上佳选择。

如果你动心了,接下来就是那省略的一万字……

痛点 – 团队分工与协作

在前后端分离的开发方式中,拆故事卡是个难题。

如果前后端同时工作于一张卡上,但配合不够默契或节奏不同步,就会出现一方空转的现象。如果前后端各一张卡,又不容易实现端到端验收,可能导致先做完的一方在另一个结束后还要再次返工的现象。而且,两个人都要深入理解这张卡所描述的业务细节,而这往往是不必要的。

更重要的是,BUG 最容易出现在边界处。

业务卡不像技术卡那样能跟其它卡片划出明确的边界,前后端之间必然具有千丝万缕的联系。这种联系越紧密,出 BUG 的机会也就越大。

技术架构上的挑战,也会反映到人员架构上。我们人类不是星灵,无法做到心灵相通。因此前后端开发者需要对合作方所拥有的知识进行很多主观假设。

如果这些假设中存在错误,又没能及时沟通来消除它(甚至可能都意识不到这些假设的存在),那么 BUGs 就要登场了。而像业务卡这种级别的密切协作中可能隐含的假设实在太多了,除非经过长时间的磨合,否则很难消除,但大多数项目上可没有那么多磨合时间。

解决方案 —— 全栈式开发

人员架构

该如何解决呢?克服上述问题的办法就是全栈式开发。也就是说,调整人员架构去适应技术架构。

简单来说:每个人都同时写前端和后端。他不必是前端专家也不必是后端专家,但是两边都要会写。他的关注点不是技术知识,而是业务知识。他的工作目标是贯穿前后端的价值流,对单个故事进行端到端交付。

但是,要如何克服实现中遇到的技术难题以及保障代码质量呢?那就要靠团队中的技术专家了。

总体来说,全栈式团队的人员架构就是大量全栈业务工程师 + 少量技术专家。当然,技术专家不一定要安排单独的人担任,只要技术满足要求,也可以由某位全栈工程师兼任,只是他做计划时要留出做技术支持的时间。

通过 Code Review、Pair 等敏捷实践,技术专家可以起到团队放大器的作用,让整个团队的生产力翻倍。

个人工作流

作为全栈工程师,你首先要对一个业务故事进行建模,包括业务模型、视图模型、领域模型、存储模型等,建模的过程也就是你理解业务的过程。这时候要注意多和 BA、UX、DBA 等沟通,以确保你的理解不存在方向性错误,不要太沉迷细节,防止见木不见林。

单源建模的优点是这些模型之间很容易保持一致,这无论是对前期开发还是对后期维护都是有帮助的。

建模完毕之后,就要开始设计前后端之间的接口了。接口是前后端分离式架构中最容易开裂的地方,也是对未来的演化影响最大的地方之一。它很重要,但也不必小心翼翼的 —— 全栈工程师对接口变化的适应能力要强大得多。因为接口的提供方和消费方都是你,信息非常透明,不存在任何额外的假设。对不完美的接口,你可以在后续开发过程中迭代好几个版本来把它打磨到最理想的形态,改接口将不再沉重和危险。

接口设计完之后,有两种路径,取决于界面和后台逻辑的特点。

如果对业务理解还不是很有信心,那就先用 Mock 的方式把前端写出来,然后把这个 Mock 版当做可执行的原型去跟 BA、QA,甚至客户进行实际操作演示,用可操作原型来验证你对业务的理解。对一般粒度的故事卡,线框图级的可操作原型通常能在半天内完成。通过原型尽早发现错误,可以避免以后沉重的返工。而且,这是一个可演化原型,不是一次性原型,不会浪费掉。

如果后端很容易实现(但先不必做优化工作),那么就可以不必 Mock,先初步完成后端开发,并让前端直接对接真实的后端。先拿这个比 Mock 版原型更逼真一点的原型串起流程,然后再进行优化和打磨工作。

在整个过程中,你可以根据不同的需要,来与不同的技术专家进行 Pair,并且你最终的代码也会在例行 Code Review 中得到前端专家、后端专家、DBA、DevOps 专家等人的点评和改进,不必担心自己在单项技术上的短板影响交付。

全栈的挑战

全栈固然美好,但也要迎接很多挑战,而 Angular 会帮你分担这些痛苦。

首先遇到的挑战是语言切换

前后端 JavaScript 全栈固然在特定场景下有效,但是在很多企业应用中是远远不够的。至少到目前为止,企业应用还主要是 Java 的天下。本文所讨论的也都是 Java + JavaScript 的全栈。

我们都知道,Java 和 JavaScript 之间的差异就像雷锋和雷峰塔之间的差异。Java 程序员通常很难适应 JavaScript,不过现在有了更像 Java 的 TypeScript。而 Angular 就是原生基于 TypeScript 的框架,稍后我会做一个摘要讲解,你会发现自己很熟悉它的味道。

(图片来自:http://t.cn/RobG5nA

其次是基础设施

基于 JRE 的构建体系和基于 NodeJS 的构建体系看似差异很大,实际上却有很大程度的相似性。但前端两年一换代的疯狂迭代,以及层出不穷的新名词、新工具,仍然难免会让后端心生恐惧。不过不用担心,Angular 替你封装了一切,你只需要装上 NodeJS 环境和 Angular CLI 就可以了。你不需要关心它封装了哪些第三方工具,至于今后的工具链怎么疯狂迭代,那都是 Angular 开发组需要操心的事。

最后是最佳实践

前后端从表面上看差异很大 —— 前端轻灵,后端稳重。

但在我看来它们很少存在本质性的差异,更像是不同的社区文化导致的结果。而在更高的层次上看,两边的技术具有很大的相似性。无论是函数式编程还是工程化开发,都不是某一方所特有的,而是 IT 领域的共同资产。况且,它们还一直在相互影响,相互渗透 —— 这两年后端变得越来越轻灵,而前端变得越来越工程化。长远来看,文化合流是必然的趋势。

事实上,前后端很多优秀设计和最佳实践都是殊途同归的。像 Spring 和 Angular,它们都采用了久经考验的面向对象范式;都使用依赖注入技术进行解耦;都拥抱函数式编程;都提供了丰富的 AOP 支持等。虽然细节上各有千秋,但仅从代码上就能感受到它们之间的相似性。

我该怎么办?

听完这些,你是否已经蠢蠢欲动?接下来,就跟我开始 Angular 之旅吧。

语言 – TypeScript

Angular 使用 TypeScript 作为主要开发语言。如果你还不熟悉 TypeScript,那可以把它看做 Java 和 JavaScript 的混合体。TypeScript 是 ES6 的超集,这就意味着,任何有效的 ES6 语法都同样是有效的 TypeScript 语法。

事实上,从 Java 出发学 TypeScript,可能比从 ES5/6 学 TypeScript 还要简单一些。不过,对于 Javaer 来说,学习 TypeScript 时有一些重要的不同点要特别注意。

TypeScript 的类型只存在于编译期

TypeScript 的一个首要设计约束就是要兼容 ES5/6,因此不能随意增加基础设施,而像 Java 这种级别的类型支持在原生 JavaScript 中是根本不存在的。

你可以把 TypeScript 的类型看做仅仅给编译器和 IDE 用的。因此,在运行期间没有任何额外的类型信息(只有 ES5 固有的那一小部分),像 Java 那样完善的反射机制是很难实现的(可以用装饰器/注解实现,但比较繁琐)。

TypeScript 的装饰器 vs. Java 的注解

TypeScript 的装饰器和 Java 的注解在语法上很相似,但其实在语法含义上有着本质的区别。TypeScript 的装饰器是个函数,而 Java 的注解是个数据。语法上,装饰器名字后面必须带括号,不能像注解那样省略。

不过,在 Angular 中,TypeScript 装饰器的实际用途就是为类或属性添加注解而已。因此,有些文章中,包括早期的官方文档中,用的都是注解的说法。当然,以后写新文章还是都用装饰器吧。

类与接口

TypeScript 中的类和 ES6 中的类几乎是一样的,和 Java 中的类也很相似。

接口则不同,我们前面说过,TypeScript 中的类型信息只存在于编译期,而接口作为“纯粹的”类型信息,也同样只存在于编译期。也就是说,在运行期间你无法判断某个对象的类是否实现了某个接口。在 Angular 中,实际上使用的是暴力探测法来判断的:查找这个接口中规定的方法(只匹配名称),如果存在,则认为实现了这个接口。

这也意味着,你就算不显式 implements 接口,但只要声明了其中的方法,Angular 也会正确的识别它。但这不是一个好习惯,你应该始终显式 implements 接口,删除时也要同时删除接口声明和对应的方法。不过也不用担心,Angular 自带的 lint 工具会帮你检查是否有忘了显式 implements 接口,多注意提示就可以了。

接口是给编译器和 IDE 看的,这很有用。比如,我们可以在 IntelliJ/WebStorm 中声明某个类实现了一个接口,然后在这个类名上按 alt-enter ,就会出现 “Implement interface XXX” 菜单 —— 就像 Java 中一样。事实上,一些 IDE 对 TypeScript 的支持程度已经接近 Java 了:代码提示、重构、类型检查、简短写法提醒等,应有尽有。

值得注意的是:你也可以 implement 一个类,而不仅是 extends 它,也就是说类可以在很多场景下代替接口!Angular 风格指南提出,“考虑在服务和可声明对象(组件、指令和管道)中用类代替接口”。因为运行期间接口不存在,所以在 Angular 中不能把接口用作依赖注入的 Token,也就不能像 Java 中那样要求注入一个接口,并期待框架帮你找出实现了这个接口的可注入对象,但类存在,因此,上述场景下要尽量用抽象类来代替接口。

鸭子类型

为了支持 JavaScript 的动态性和遗留代码,TypeScript 的类型匹配要比 Java 宽松不少。比如,如果两个类(或接口)的属性和方法(名称、类型)都完全一致,那么即使它们没有继承关系,也可以相互替代(但如果类有私有属性,则不能,就算两者完全一样也不行)。表面上看这可能过于宽松了,但在实际开发中还是很有用的,使用中要注意突破 Java 固有思维的限制。

在 TypeScript 中还支持可选属性(name?: Type),也就是说如果两个类的差别仅仅在可选属性上,那么它们也是可以相互替代的。

字面量与匿名类型

TypeScript 在某些方面可能更符合你对 Java “应该是什么样子”的期待,至少在我看来是这样。要声明一个匿名对象、匿名数组型变量?直接写出来就好了const user = {name: 'tom', age: 20}。除此之外,它还能声明匿名类型 let user: {name: string, age: number} = ...

当然,也不能滥用它们。对于一次性使用或暂时一次性使用的变量或类型,用字面量和匿名类型很方便,可读性也好,但是如果它要使用两次以上,那就该重构成正式的类型了。

any

TypeScript 中的 any 大致相当于 Java 中的 Object,如果你看到通篇 Object 的 Java 代码你会不会想骂街?any 也一样。不必完全禁止 any,但如果你要使用 any,请务必先想清楚自己要做什么。

void

如果你在 Java 中经常使用 void,那就遵循同样的原则用在 TypeScript 中。在 TypeScript 中,当你不声明函数的返回类型时,它会返回自动推断的类型(没有明确的 return value 语句时会推断为 undefined 类型),如果你不想返回任何值,那么请把返回类型指定为 void 来防止别人误用。

this

JavaScript 中的 this 是个奇葩。虽然这是函数式语言中的标配,但从语言设计上真是让人忍不住吐槽。要是能像 Groovy 那样分出 this / owner / delegate 就好了。

吐槽归吐槽,对于 Java 程序员,该怎么避免自己踩坑呢?很简单:对普通函数,任何涉及到 this 的地方都用箭头函数 ()=>,而不要用普通的 function foo(),因为前者是替你绑定好了符合直觉的 this 的;对方法,不要把任何涉及到 this 的方法当作函数指针传给别人,但可以在模板中自由使用。在 Angular 中,这两条原则可以帮你回避掉绝大部分 this 错误。更多的细节可以先不管,随着使用经验的增加,你会逐渐弄明白这些规则的。

其它

以上这些是开发中常遇到的注意事项,其它的特性我就不一一列举了,请自行参考 TypeScript 的官方文档。

范式与模型

MVVM

Angular 的基本编程模型是 MVVM,你可以把它看做 MVC 的一个变种。事实上,这是一个很符合直觉的模型:你看到一个页面,先在大脑中抽取出它的信息架构(属性)和操作(方法),定义好它们之间的逻辑关系和处理流程,这就是视图模型(VM)。你把它们落实到代码,变成内存对象,然后 Angular 就会帮你把它和页面(View)关联起来。你不懂怎么操作 DOM?没关系,你只要会操作内存对象就可以了,这应该是你非常擅长的吧?剩下的那些脏活儿 Angular 都会帮你搞定。

不过,Angular 关心的只是“要有” VM,至于你如何生成这个 VM,它并不会做任何假设和限制。

自由混搭与切换

你想怎么生成 VM?

  • 像后端控制器那样直接写在组件中?没问题!
  • 像后端那样委托给服务?没问题!
  • 像 Redux 那样委托给单一 Store?没问题!
  • 像 Java 8 Stream 那样用流水线生成?没问题!
  • 自己几乎不处理,完全委托给后端 API?没问题!

这么多方式各有不同的适用场景,但也不必过早担心如何选型。只要你的组件设计合理(职责分明、接口明确等),那么在这些方式之间切换,或者混用它们,都不会很难。

作为起点,可以先直接写在组件中,然后按需重构成服务,服务中可以直接写代码,也可以实现 Redux 风格的单一 Store,或者用 RxJS 写流水线。

RxJS

在 Angular 开发人员的成长过程中,有一个很重要的坎就是 RxJS,它的背后是 FRP(函数响应式编程)范式。不过对于 Javaer 来说,它的门槛并不高。如果你会用 RxJava / RxGroovy 等 ReactiveX 族的任何一个库,那么你几乎可以不用专门再学,它们都是同一个大家族,编程范式甚至部分操作符的名称都一样,稍微对比一下差异就可以了。如果不会,请继续往下读(以下的讨论也适用于 RxJava 等,不过我文中只用 RxJS 举例)。

RxJS 是一种 FRP(函数响应式编程)库,它同时具有函数式编程和响应式编程的优点。

如果你会用 Java 8 Stream,那么也有很多知识可以复用到这里。相对于 Java 8 Stream,RxJS 的限制稍微宽松一些,但我建议你仍然按照 Java 那种严格的方式使用它(比如不要对流外的变量赋值)。

所谓响应式编程,我们可以把它想象成一条流水线,流水线上不断传送待加工的材料(原料、半成品、成品等),流水线上每个工序的工人负责对传送到眼前的材料进行一定的处理(做出响应),然后放回流水线,接着它就会被传送到下一个工序。

设计上,每个工序的职责都应该是明确而单一的,这样才能达到最高的效率和流水线的可定制性。

把这些概念映射到 RxJS,流水线就是 Observable(可观察对象),工序就是 operator(操作符),材料就是传给每个 operator 的参数。

是不是感到很熟悉?没错,它跟 MessageQueue 是一样的模型,只是应用在不同的层次而已。在编程领域,这种现象随处可见,善于发现并掌握这种现象,是你作为资深程序员能实现快速跨领域学习的根本保障。

相对于 Java 8 Stream,RxJS 比较特别的一点是它完全屏蔽了同步和异步之间的差异。也就是说,其中的 operator 不知道也不需要关心这个数据是同步传过来的还是异步传过来的。只要你遵循一些显而易见的原则,你就可以一直用同步方式给数据,之后即使要突然改成异步,原有的代码也不会被破坏。

事实上,我在 Angular 开发中经常利用这种特性来加速开发。比如假设我最终需要从后端 API 获取某些信息,在这个 API 开发好之前,我可以先在前端模拟出响应结果,进行后续开发。这时候,如果我用 Observable 的方式声明数据源,那么虽然我目前用同步的方式提供数据,但是将来我可以直接切换成 HTTP 数据源,而不用担心破坏现有代码。

细部原理

宏观上的要点已经讲完了,接下来我们快速过一遍微观的。我只讲要点,要想深入细节请参阅文中给出的参考资料。

Angular 模块

Angular 模块不同于 JavaScript 模块,它是一个架构级的基础设施,用来对应用进行宏观拆分,硬化边界,防止意外耦合。

模块的划分主要基于业务领域的边界,而在开发组织形式上,也要和模块划分方式相互对齐,尽量让每个模块都有明确的负责人。

参见 https://angular.cn/guide/ngmodules

路由

传统的路由功能完全是由后端提供的,但是在单页面应用中,在页面中点击 URL 时,将会首先被前端程序拦截,如果前端程序能处理这个 URL,那就会直接在前端处理,而不会向后端发送这个请求。

前端可以根据这个 URL 修改视图,给用户与后端路由一样的结果,但省去了网络交互的过程,因此会显得非常快捷。

路由是业务功能的天然边界,善用路由对于改善代码结构和可维护性是很有帮助的。

在 Angular 中,路由还同时提供了惰性加载等特性,因此,早期对路由进行合理规划非常重要。不过也不用过于担心,Angular 中重新划分路由的代价并不高。

参见 https://angular.cn/guide/router#appendix-emlocationstrategyem-and-browser-url-styles

模板与视图

你可以把模板看做 JSP,主要区别是 JSP 是后端渲染的,每次生成都需要一次网络交互,而模板是前端渲染的,在浏览器中执行模板编译成的 JS 来改变外观和响应事件。

模板语法

虽然看起来奇怪,但 [prop](click)*ngFor 等模板语法中的特殊符号都是完全合法的 HTML 属性名,实际上,属性名中只禁用各类空白字符、单双引号等少数几个显而易见的无效字符(正则:[^\t\n\f \/>"'=])。

参见 https://www.w3.org/TR/2011/WD-html5-20110525/syntax.html#syntax-attribute-name

属性与……属性

由于历史原因,英文的 Attribute 和 Property 都被译为属性,然而两者是截然不同的。Angular 中的常规绑定语法针对的都是 Property,只有 [attr.xxx] 绑定针对的是 Attribute。

参见 https://angular.cn/guide/template-syntax#html-attribute-vs-dom-property

组件与指令

你可以把组件看做后端模板中的 taglib,区别是它们运行在浏览器中而不是服务端。组件与指令在用途上的区别是,组件充当搭建界面的砖块,它的地位和 HTML 元素并无区别;而指令用于为 HTML 元素(包括组件)添加能力或改变行为。

所以,组件中不应该操纵 DOM,只应该关注视图模型,而指令负责在模型和 DOM 之间建立联系。指令应该是单一职责的,如果需要完成多个职责,请拆成多个指令附加到同一个元素上。

服务与依赖注入

Angular 的服务与依赖注入和 Spring 中的很像,主要的区别是 Angular 是个树状的多级注入体系,注入器树是和组件树一一对应的,当组件要查找特定的服务时,会从该组件逐级向上查找,直到根部。

这实际上是职责链模式。当前组件找不到某个服务时,就会委托给其父节点来查找。和策略模式结合使用,组件就可以通过自己提供一个服务来替换父组件提供的服务,实现一种支持默认处理的逻辑。

参见 https://angular.cn/guide/hierarchical-dependency-injection

表单与验证

在前端程序中,验证主要是为了用户友好性,而不是安全。安全是后端的工作,不能因为前端做了验证而放松。

Angular 对表单提供了非常强力的支持。如果你的应用中存在大量表单、大型表单、可复用表单或交互比较复杂的表单,那么 Angular 的表单功能可以为你提供强大的助力。

Angular 的表单提供了不同层级的抽象,让你可以在开发中轻松分离开显示、校验、报错等不同的关注点。也让你可以先用文本框快速搭出一个表单,将来再逐个把这些文本框替换成自定义编辑框,而不会破坏客户代码。

参见 https://angular.cn/guide/user-input

测试

Angular 对测试的支持非常全面,可以实现各个不同层次的测试。

但是不要因为拿到把这么好用的锤子就满世界敲。要根据不同的价值需求去决定测什么不测什么。

别忘了每个 Angular 的类,无论服务、组件、指令还是管道等,都是 POJO,你可以用测 POJO 的方式测试它们,得到毫秒级反馈,而且这往往会更高效。

参见 https://angular.cn/guide/testing。但要记住:虽然 Angular 支持这么多种方式,但你不一定要用到这么多种方式。

安全

在 Angular 中,你不会无意间造成安全隐患。只要注意一点就够了:DomSanitizer.bypassSecurityTrust* 要慎用,务必确保传给它的东西不可能被攻击者定制,必要时请找安全专家推演。参见 https://angular.cn/guide/security#sanitization-and-security-contexts。

如果你在发起 POST 等请求时收到了 403 错误,那可能是因为后端开启了 CSRF 防护。Angular 内置了一个约定 —— 如果服务端 csrf token 的cookie名是 XSRF-TOKEN,并且能识别一个名叫 X-XSRF-TOKEN 的请求头,那么它就会自动帮你完成 CSRF 验证。当然,你也可以自定义这些名称来适配后端,参见 https://angular.cn/guide/http#configuring-custom-cookieheader-names

跨域与反向代理

本地开发时,前端有自己的服务器,显然无法与后端 API 服务器运行在同一个端口上,这样就导致了跨域问题。要解决跨域问题,主要有 CORS 和反向代理这两种方式。CORS 是标准化的,但是受浏览器兼容性的影响较大;而反向代理则通过把 API “拉”到前端的同一个域下,从根本上消除了跨域访问。

开发时,Angular CLI 内置了对反向代理的支持;部署时,各个主流 Web 服务器都能很好地支持反向代理。

一般项目中建议还是优先使用反向代理的方式。

(图片来自:http://t.cn/RgsWKEJ

杂谈

你不必写 CSS

很多后端初学前端时会被卡在 CSS 上,在心里喊一句 WTF。但实际上,在团队开发中,你可能根本不必写 CSS。

现在已经有了很多现成的 CSS 库,比如已经熟透的 Bootstrap,还有后起之秀 Material Design、Ant Design 等等。你只要能根据其表达的视觉含义,正确套用它们定义的 CSS 类就够了。尽量不要自己手写 CSS,否则可能反倒会给将来的页面美化工作带来困扰。

选好了基础框架,并且和 UX 对齐之后,团队中只需要一个 CSS 高手就能实现所有的全局性设计规则。对于全栈工程师来说,充其量只有对当前页面中的少量元素进行定制时才需要写 CSS,况且还可以通过找人 pair 来解决偶尔碰到的难题。

全栈,让设计更简单

前后端技术各有所长,有些事情用前端实现更简单,有些用后端实现更简单。综合考量前端技术和后端技术,往往可以产生更简单、更优秀的设计。广度在业务开发中往往比深度有用,这也是全栈工程师的优势所在。而团队中的技术专家主要负责深度。

分工是动态的

技术专家或全栈工程师,并不是什么荣誉头衔,只是分工不同而已。

同一个项目上你可以同时担任全栈工程师和技术专家;这个项目你是全栈工程师,下一个项目上也可能专门担任技术专家。团队的协作方式永远是动态的、随需应变的。

不用担心全栈会限制你的技术深度,实际上,全栈对提高你的技术深度是有帮助的,因为很多技术的“根”都是互通的。

相信你的直觉

资深后端首先是一个资深程序员,你对于“应该如何”的期待,很可能是对的。如果你觉得 Angular 应该有某项功能或某种设计,它很可能确实有。去 Stackoverflow 搜一下,找找你的答案,这是你成为高级 Angular 程序员的捷径。

万法归一

形容某人聪明时经常说“万法皆通”,实际上“万法皆通”不如“一法通而万法通”。很多技术之间的相似程度超出你的想象,这些相似的部分其实就是技术的核心。用万法归一的思路去学习总结,会给你带来真正的提高。

资料 & 学习指南

学习 Angular 的最佳资料是它的官方文档,它无论是从准确、全面,还是及时性等方面都是最佳的。

它的英文文档站是 https://angular.io,中文文档站是 https://angular.cn,这是由我和另外两位社区志愿者共同翻译的,期间还得到了很多社区志愿者的支持。中文文档和英文文档至少在每个大版本都会进行一次同步翻译。虽然时间有限导致语言上还有粗糙之处,不过你可以相信它的技术准确度是没问题的。

阅读时,请先阅读架构概览 https://angular.cn/guide/architecture,然后阅读教程 https://angular.cn/tutorial(有经验的程序员不需要跟着敲代码,如果时间紧也可跳过),最后阅读风格指南 https://angular.cn/guide/styleguide。风格指南很重要,不用记住,但务必通读一遍,有点印象供将来查阅即可。

文档站中还提供了 API 参考手册,它提供了简单快速的站内搜索功能,需要了解哪些细节时到里面查就可以了。

另外,ng-zorro 组件库的一位开发者还整理了一份不完全指南,包括中英文资料:https://zhuanlan.zhihu.com/p/36385830


更多精彩洞见,请关注微信公众号:思特沃克

Share

Rec:一个项目的诞生

Rec是一个用来验证和转换数据文件的Java应用。从第一行代码到v1版本成形,仅仅经历了一个半月的时间,作为一个开源项目,在很多方面都有着各种各样的纠结。

需求

Rec的需求源自于我们团队所做项目的特殊性:遗留系统迁移。在工作中,我们需要跟各种团队打交道,每天处理各种来自ETL(Extract、Transform、Load)过程中的数据和程序问题,而整个ETL程序运行起来过于笨重,并且还要考虑准备后端数据和各种验证问题,非常不方便。

其实在此之前,只要有一些简单的程序跑起来、能够进行一些简单的检查,比如唯一性(uniqueness)、关联关系等等,就可以在很大程度上减少我们在ETL过程中花费的时间。并且,这半年多来的实践也证实了这一点。

最初,同事的建议是写一个脚本文件来解决这个问题,这对于程序员来说当然不是什么大问题。但随着使用次数的增加,我渐渐发现一套Python脚本并不能胜任:一方面,面对复杂的业务场景,很难有一套灵活的模式去匹配所有的数据格式;另一方面,随着数据量的增长,性能也成了一个大问题。

于是我开始着手设计和实现Rec。

设计

Rec第一个可用版本的设计共花了七天的时间,基本上具备了我期望的各种能力:

  • 可以自定义数据格式
  • 能够进行简单的唯一性和关联关系验证
  • 支持一些扩展的查询语法:比如,可以验证多字段组合的唯一性
  • 性能上基本能够胜任

Rec面向的数据文件格式是类CSV的文件,包括其他的一些使用分号(;)或者竖线(|)来做分隔符的文件。出于习惯,文件的Parser并没有选取现成的库,而是我自己按照Wikipedia和RFC4180的规范写出来的,基本上能够解析所有类似的文件。而且还有一个意外的发现:用空格做分隔符的文件(比如,某些日志)也是可以支持的。

对于每一条数据,Rec提供了两部分组件,一部分是数据本身,另一部分是该数据的访问器(accessor)。访问器提供把字段名转换成对应数据项下标的功能:跟Spring Batch中的FieldSetMapper很像,当然在其之上还多了一层语法糖。

一个典型的accessor format如下:

first name, last name, {5}, phone, …, job title,{3}

其中,“…”表示中间的字段全部可以忽略,{3}和{5}是占位符,表示在这些字段之间有如此多个字段也是可以忽略的。而由“…”分割成的两部分也是有差异的:在其后的字段使用的是类似Python的负数下标;换句话说,我并不需要知道本来的数据有多少个字段,只需要知道我要获取的倒数第几个是什么就可以了。

Rec的验证规则也是从简设计。由于最初的需求只有唯一性检查和关联关系检查,所以第一个版本里面就只加入了这两个功能,语法如下:

unique: Customer[id]
unique: Order[cust_id, prod_id]
exist: Order.prod_id, Product.id

每一行表示一个规则,冒号前面是规则的名字,后面是规则所需要验证的数据查询表达式。对于查询表达式,这里需要提一点,本来是设计了更多的功能,比如过滤和组合等等,在后面扩展的时候发现在语法上很难实现得更直观而且方便使用,于是就决定改用嵌入脚本引擎的方式来解决。

另外Rec第一个版本发布只有Kotlin运行时的依赖,所以完整的Jar文件只有2MB。同时,只要给对应的数据文件提供.rec格式的描述文件,再在同一目录创建一个default.rule来加入各种检验规则,就可以运行、然后得到你想要的结果了。

扩展

Rec的第一个版本在某些方面是达到预期结果了的。但在那之后就发现了一些很重要的问题:首先,我们另一层的需求并没有得到满足:Rec能够帮我们验证并且找到有问题的数据,但是不能够按需来选择我们想要的内容;其次,在检查数据的同时,我们也隐含地有集成和转换数据的需求,Rec也不能够满足。

于是第一个星期以后我开始考虑对Rec进行扩展。首先是在同事的建议下把乱成一坨的代码分成多个module;其次考虑加入前面提到的过滤和格式转换的功能。

第一个步骤勉强算是完成了,但是卡在了第二步上:对于转换的规则,要不要和验证的规则放在一起?如何对这两种规则做区分?如何在过滤器中设计变量引用等细节?每一个问题都让我纠结了很多,直到最后决定放弃这一步,直接通过引入脚本引擎来实现:从最初hack Kotlin编译器的嵌入版,到决定用JavaScript,到放弃Nashorn转而用Rhino,中间虽然辗转几次又遭遇了不少坑,但毕竟有成熟的社区经验辅以指导,还是顺利地走了下来。

Test Driven Development vs Test Driven Design

其实直到现在Rec的测试也只有少量的一些。而且在拆分模块的时候,因为测试代码之间的依赖比较多,并没有做拆分,所以基本上还是集中在一个模块中。当然这也是很多时候我自己做项目时的一个习惯:并不会完全以TDD的方式来开发,而是把单元测试作为一个验证设计思路的手段。因为很多时候思路转变的太突然,不实现的话估计下一秒钟就完全变了。而且,作为一个简单的工具类程序,并不需要重度面向对象的设计,如何规划和设计流畅易用的接口就成了必须考虑的一个问题。这个时候测试的设计性变得更明显。

另外,对于Parser这种东西,测试是必不可少的,但是要TDD一个Parser出来,基本上就是在给自己找活干了。所以这种时候,我会先加一些基本的case,来确保能够正常的实现功能,然后再引入一些比较corner的case来确保实际的可用性。对我来说,这是完全没有问题的:当然后面的实践验证了这一点,Rec没在解析文件方面出现过任何问题。

Kotlin vs Java(Script)

最初采用Kotlin就是因为它有很多优点,而且这些优点也确实影响了Rec的设计,但是因为各种原因,还是被替换了两次。首先迟迟不发布的1.1版本和编码兼容性的诸多问题,导致我决定用原生Java换掉Kotlin。当然,这也导致了不得不强行舍弃很多好用的编译期检查和语法糖,以及一个用来做bean mapping的组件。

至于采用JavaScript,则是另外一个问题。

众所周知,JSR223定义了一套JVM平台的脚本引擎规范,但是作为一个强静态类型的编译型语言,Kotlin想要契合这套规范还是很困难的,于是无论是官方的实现还是Rec的解决方法,都不是很好:

首先你要启动一个JVM来执行这个脚本的动作;在这个动作里面,启动第二个JVM要调用Kotlin的编译器来将该脚本编译成class;然后这个编译器会再去利用自定义的classloader来加载和执行这个class文件。当所有的功能都集中在一个Jar文件里面的时候,每次都要选择指定classpath等选项,实现非常复杂。而且,由于第二次执行的Kotlin编译器是识别不到你已引入的kotlin-reflect类库的(因为已经统一包装到rec的jar包里面去了),就会导致脚本中bean mapper的一些功能根本不能使用。万般无奈,选择采用更成熟的JS引擎。

当然选择JS带来的一个好处就是,有更多人可以拿来就用了,而且,最新的Rhino提供了CommonJS扩展,能够顺手require所需的JS文件,在复用和模块化方面也能够有不少提升。

技术抉择

除了部分Parser相关的代码外,Rec采用的基本都是不可变的数据结构:一方面是因为使用Kotlin;另一方面,在整个模型里面并没有特别的需求会涉及更改数据。唯一的担心是内存占用,但是后来发现这部分担心也是不必要的,因为所有内存的瓶颈只在数据文件的Parser上,项目中的数据条目动辄数十个数据项,几十万条数据,再加上每次parse都会把一个字符串分割成多个,最后再合并到一个大的集合里面,在最开始设计的时候没有考虑这一点,轻轻松松就爆了JVM堆。这也是后期需要着重优化的一个方面。

另外一个点是关于异常处理。对于Java应用来说这是个巨坑:异常本身并没有问题,但是由于checked和unchecked的区分以及众多设计哲学的不同,所以就成了争议点所在。在这里我参考了Joe Duffy的做法。对于严重的不可重试的错误,比如文件找不到,空指针异常,下标错误等,直接让程序die(没错,就是PHP中的那个die),至于数据格式错误等问题,更多的做法是做一条记录然后选择继续。当然这一套东西并不依赖Java的异常系统,只是作为一个设计原则来应用,毕竟这不是一个App server,并不需要高可用的保障,相反这种fail fast的直接反馈更有助于发现和解决问题。

在类型系统上,最初实现Rec的语言是Kotlin,它提供了一套比Java略微高级一些的类型系统。当然主要的点还是在于nullable:在功能上,nullable与Java 8的Optional类似,用来容纳可以为空的值,同时能够有效避免空指针异常;在实现上,比Java略微高出了一点的是,非nullable的对象必须被初始化并且不容许为null。这直接解决了Optional对象为空的尴尬问题。

当然,由于运行时的依赖还是无法避免地使用JVM,而且没有自定义值类型的支持,在使用Kotlin,特别是跟Java标准库和其他框架结合使用的时候,还是会遇到空指针的坑。但是在这一点上,Kotlin给我们开了个好头,比如在后面convert到Java的过程中,我也尽量保证各种对象都是final并且被非空初始化了的。

结语

当然也许很多人会说,Unix那套工具用的很顺手的话,上面说的这些都不是问题,其实Rec本来的思路也是来自于它们:accessor来自于awk的列操作模式,scripting中的过滤器来自于sed和grep,链式调用源于管道。Rec也只是在这些思路之上加了一些方便的操作而已。但是对于我个人来说,这种折腾其实是在检验我自己的理论和思考,更别说还提升了项目的生产力。也许哪一天实在受不了了,还可以拿C++和Lua重写了呢。毕竟,生命不息,折腾不止。

硬广的最后,欢迎来贡献文档、issue、PR、星星和转发分享: https://github.com/rec-framework/rec-core


更多精彩洞见,请关注微信公众号:思特沃克

Share

阿里巴巴Java开发手册评述

注:本文基于阿里巴巴技术手册的1.0.2版本编写

2016年底,阿里巴巴公开了其在内部使用的Java编程规范。随后进行了几次版本修订,笔者当时看到的版本为v1.0.2版。下载地址可以在其官方社区——云栖社区找到。

笔者作为一名有数年工作经验的Java程序员,仔细研读了这份手册,觉得是一份不可多得的好材料。正如阿里巴巴在发布时所说,“阿里巴巴集团推出的《阿里巴巴Java开发手册(正式版)》是公司近万名开发同学集体智慧的结晶,以开发视角为中心,详细列举了如何开发更加高效、更加容错、更加有协作性,力求知其然,更知其不然。结合正反例,让Java开发者能够提升协作效率、提高代码质量。” 同时,阿里巴巴也期望这套Java统一规范标准将有助于提高行业编码规范化水平,帮助行业人员提高开发质量和效率、大大降低代码维护成本。

(图片来自:http://t.cn/R63jrWi)

其实早在多年前,Google就已经把公司内部采用的所有语言的编码规范(其称为Style Guide)都开源在Github上。这份清单中包括了C++Objective-CJavaPythonRShellHTML/CSSJavaScriptAngularJSCommon LispVimscript等语言的编程规范。并且Google还发布了一个用于检查样式合规性的工具cpplint以及在Emacs中使用Google编程样式的配置文件google-c-style.el。看来Google中Emacs粉比Vim粉要强势的多。

Google为什么要发布这样的Style Guide呢?因为它认为几乎所有的开源项目都需要有一组约定来规范如何编写代码。如果项目中的代码都能保持一致的风格,那么即使代码再多也会很容易的被人理解。

Google的这份编程规范包含了很多方面,从”对变量使用camelCase命名法”到”绝不要使用全局变量”到”绝不允许例外“等。其Java编程规范包含7大部分,分别为介绍、源文件基本要求、源文件结构、格式化、命名、编程实践和Javadoc。每一部分又细分为很多子条目。如果采取条规范的原因不是很容易理解,都会配有相应的示例或者引用文章。

由于Google的这份编程规范目前只有英文版本,所以在中国的程序员中只有少部分人知道它的存在。并且只有更少的团队在真正的应用它,其中就包括我的团队。我们团队根据Google的Java style guide也演化出了自己的团队版本,放置在团队共享wiki上供大家随时查阅。我们根据自身的项目特点丰富了”编程实践”里的内容,并且新加入一个章节来描述编写Java代码的一些原则,比如简洁代码、组合优于继承、stream优于for循环等。

我想阿里巴巴发布的Java开发手册之所以叫做”开发手册”,而不是像Google那样叫做“Style Guide(样式风格)”,是因为它不仅仅局限于style guide这一方面,而是以Java开发者为中心视角,划分为编程规约、异常日志规约、MYSQL规约、工程规约、安全规约五大块,再根据内容特征,细分成若干二级子目录。根据约束力强弱和故障敏感性,规约依次分为强制、推荐、参考三大类。

该开发手册中的每一条都值得了解。限于篇幅原因,这里只列出”编程规约“中有感受的几条来评述一下。

15. 【参考】各层命名规约:

A) Service/DAO 层方法命名规约

1) 获取单个对象的方法用 get 做前缀。

2) 获取多个对象的方法用 list 做前缀。

3) 获取统计值的方法用 count 做前缀。

4) 插入的方法用 save(推荐)或 insert 做前缀。

5) 删除的方法用 remove(推荐)或 delete 做前缀。

6) 修改的方法用 update 做前缀。

B) 领域模型命名规约

1) 数据对象:xxxDO,xxx 即为数据表名。

2) 数据传输对象:xxxDTO,xxx 为业务领域相关的名称。

3) 展示对象:xxxVO,xxx 一般为网页名称。

4) POJO 是 DO/DTO/BO/VO 的统称,禁止命名成 xxxPOJO。

命名规约的第15条描述了在Service/DAO层对于资源的操作的命名规范。这一条的参考价值极大,因为我所有待过的团队对于这一点都没有明显的约束,每个团队都有五花八门的实现。如果能遵守这一点,那么我们在操作资源时就会减少一些困扰。

2. 【强制】long 或者 Long 初始赋值时,必须使用大写的 L,不能是小写的 l,小写容易跟数字1混淆,造成误解。

说明:Long a = 2l; 写的是数字的 21,还是 Long 型的 2?

这是常量定义的第2条。从这一点可以看出阿里巴巴对代码可读性的细节扣的很严格。我也很赞同这一点。代码只需编写一次,而会被查看无数次,所以要力争在第一次编写的时候尽可能少的引入歧义。

1. 【强制】大括号的使用约定。如果是大括号内为空,则简洁地写成{}即可,不需要换行;如果是非空代码块则:

1) 左大括号前不换行。

2) 左大括号后换行。

3) 右大括号前换行。

4) 右大括号后还有 else 等代码则不换行;表示终止右大括号后必须换行。

格式规约的第1条终于终结了括号之争。这一条需要强制遵守,那么左大括号换行一派则被彻底排除在阿里巴巴之外。有人说不推荐左大括号换行,可以减少行数,增加单个屏幕可以显示的代码行数。而有的人反驳说现在屏幕已经足够大,不换行则破坏了对称之美。其实对于我来说两种格式都有各自的好处,我都可以接受,只要团队能够坚持使用其中之一即可。

5. 【强制】缩进采用 4 个空格,禁止使用 tab 字符。

说明:如果使用 tab 缩进,必须设置 1 个 tab 为 4 个空格。IDEA 设置 tab 为 4 个空格时,请勿勾选 Use tab character;而在 eclipse 中,必须勾选 insert spaces for tabs。

正例: (涉及 1-5 点)

public static void main(String[] args) {
    // 缩进 4 个空格
    String say = "hello";
    // 运算符的左右必须有一个空格
    int flag = 0;
    // 关键词 if 与括号之间必须有一个空格,括号内的 f 与左括号,0 与右括号不需要空格
    if (flag == 0) {
        System.out.println(say);
    }
    // 左大括号前加空格且不换行;左大括号后换行
    if (flag == 1) {
        System.out.println("world");
        // 右大括号前换行,右大括号后有 else,不用换行
    } else {
        System.out.println("ok");
        // 在右大括号后直接结束,则必须换行
    }
}

使用空格代替tab字符进行缩进已经成为了编程界的共识。其主要原因是不同的平台甚至不同的编辑器下tab字符的长短是不一样的。不过Google在其《java style guide》中规定缩进为2个空格,而阿里巴巴约定为4个空格。由于4个空格的缩进比2个空格的缩进长一倍,所以如果在代码嵌套过深的情况下可能会很快超过单行最多字符数(阿里巴巴规定为120个)的限制。不过这个问题可以从另一个方面进行思考,如果由于缩进的原因导致单行字符数超标,这很可能是代码设计上有坏味道而导致嵌套过深。所以最好从调整代码结构的方面下手。

6. 【强制】单行字符数限制不超过 120 个,超出需要换行,换行时遵循如下原则:

1) 第二行相对第一行缩进 4 个空格,从第三行开始,不再继续缩进,参考示例。

2) 运算符与下文一起换行。

3) 方法调用的点符号与下文一起换行。

4) 在多个参数超长,逗号后进行换行。

5) 在括号前不要换行,见反例。

正例:

    StringBuffer sb = new StringBuffer();
    //超过 120 个字符的情况下,换行缩进 4 个空格,并且方法前的点符号一起换行
    sb.append("zi").append("xin")...
        .append("huang")...
        .append("huang")...
        .append("huang");

反例:

StringBuffer sb = new StringBuffer();
//超过 120 个字符的情况下,不要在括号前换行
sb.append("zi").append("xin")...append
("huang");
//参数很多的方法调用可能超过 120 个字符,不要在逗号前换行
method(args1, args2, args3, ...
, argsX); 

关于换行,Google并没有给出明确的要求,而阿里巴巴则给出了强制性的要求。Google特别提示可以通过一些重构手法来减少单行字符长度从而避免换行,这一点我颇为认同。关于参数,很多方法调用超过120个字符需要换行,这暴露除了过长参数列的代码坏味道,解决方式之一就是使用重构手法的Replace Parameter With Method的方式把一次方法调用化为多次方法调用,或者使用Introduce Parameter Object手法创造出参数对象并进行传递。

17. 【推荐】循环体内,字符串的联接方式,使用 StringBuilder 的 append 方法进行扩展。 反例:

    String str = "start";
    for (int i = 0; i < 100; i++) {
        str = str + "hello";
    }

说明:反编译出的字节码文件显示每次循环都会 new 出一个 StringBuilder 对象,然后进行append 操作,最后通过 toString 方法返回 String 对象,造成内存资源浪费。

这是《Effective Java》以及其他文章中经常提及的优化方式,而且面试初级Java工程师时几乎是一个必考点。其实不仅是在循环体内,所有需要进行多次字符串拼接的地方都应该使用StringBuilder对象。

20. 【推荐】类成员与方法访问控制从严:

1) 如果不允许外部直接通过 new 来创建对象,那么构造方法必须是 private。

2) 工具类不允许有 public 或 default 构造方法。

3) 类非 static 成员变量并且与子类共享,必须是 protected。

4) 类非 static 成员变量并且仅在本类使用,必须是 private。

5) 类 static 成员变量如果仅在本类使用,必须是 private。

6) 若是 static 成员变量,必须考虑是否为 final。

7) 类成员方法只供类内部调用,必须是 private。

8) 类成员方法只对继承类公开,那么限制为 protected。

说明:要严控类、方法、参数、变量的访问范围。过宽泛的访问范围不利于模块解耦。思 考:如果是一个 private 的方法,想删除就删除,可是一个 public 的 Service 方法,或者一个 public 的成员变量,删除一下,不得手心冒点汗吗?变量像自己的小孩,尽量在自己的视线内,变量作用域太大,会无限制的到处跑,那么你会担心的。

这其实就是经典的原则‘Principle of least privilege’ 的体现。我们必须遵循这一原则,但不知为何阿里巴巴将其级别列为“推荐”。

7. 【参考】方法中需要进行参数校验的场景:

1) 调用频次低的方法。

2) 执行时间开销很大的方法,参数校验时间几乎可以忽略不计,但如果因为参数错误导致 中间执行回退,或者错误,那得不偿失。

3) 需要极高稳定性和可用性的方法。

4) 对外提供的开放接口,不管是 RPC/API/HTTP 接口。

5) 敏感权限入口。

8. 【参考】方法中不需要参数校验的场景:

1) 极有可能被循环调用的方法,不建议对参数进行校验。但在方法说明里必须注明外部参 数检查。

2) 底层的方法调用频度都比较高,一般不校验。毕竟是像纯净水过滤的最后一道,参数错 误不太可能到底层才会暴露问题。一般 DAO 层与 Service 层都在同一个应用中,部署在同一台服务器中,所以 DAO 的参数校验,可以省略。

3) 被声明成 private 只会被自己代码所调用的方法,如果能够确定调用方法的代码传入参数已经做过检查或者肯定不会有问题,此时可以不校验参数。

编写代码时,对参数进行校验是不可避免的。详细说又扯到“防御式编程”和“契约式编程”的话题上。这两项之所以列为参考,并没有强迫大家遵守。

6. 【推荐】与其“半吊子”英文来注释,不如用中文注释把问题说清楚。专有名词与关键字保持英文原文即可。

反例:“TCP 连接超时”解释成“传输控制协议连接超时”,理解反而费脑筋。

看到这一条我已经笑出来了。这一条说的很好,注释是用来阐述问题的,如果看了注释还一头雾水,那么这样的注释不要也罢。使用中文没什么可丢人的,解决问题才是王道。

7. 【推荐】代码修改的同时,注释也要进行相应的修改,尤其是参数、返回值、异常、核心逻辑等的修改。

说明:代码与注释更新不同步,就像路网与导航软件更新不同步一样,如果导航软件严重滞后, 就失去了导航的意义。

阿里巴巴对该条的说明非常到位。其实我们团队在编写代码时默认是没有任何注释的,因为我们追求的是self-explanatory methods。即代码本身已经就能说明它的用途。只有在很少的情况下需要添加注释。


编程规约的第九部分都是很好的tips,值得去了解和学习。

除了编程规约之外,日志规约、MySQL规约、工程规约和安全规约也都有极高的参考价值,这也是比Google的Java Style Guide出色的地方。这里就不再评述了。


阿里巴巴公布这个Java开发手册绝对是值得赞赏的事情。最后我也想给其提几点建议:

  1. 建议使用公开wiki的方式发布该手册,而不是采用pdf的方式。因为如果像google那样是公开wiki方式的话,可以方便大家参与修正和改进,并且可以看到版本历史。
  2. 该手册并没有明确的版权许可,只是在页脚处加入了“禁止用于商业用途,违者必究”的字样。Google的style guide的版权为CC-By 3.0 License,建议阿里巴巴能够指明其版权。
  3. 手册中的部分示例代码并没有遵守其列出的编程规约,有点打脸之嫌。比如以下示例代码:
     Iterator<String> it = a.iterator();
     while(it.hasNext()){
         String temp = it.next();
         if(删除元素的条件){
             it.remove();
         }
     }
    

    其while和if关键字与小括号之间并没有空格,违反了该手册中3. 【强制】if/for/while/switch/do 等保留字与左右括号之间都必须加空格。这一规则。

  4. 集合处理中可以多推荐一些Java8的集合操作方法。
  5. 有些名词没有过多解释,比如很多人可能都不知道什么叫一方库、二方库。
  6. 希望除了这份开发手册以外,阿里巴巴也可以推出对应的checkstyle配置文件以及Intellij、Eclipse的配置文件。毕竟格式化这些事都可以交由IDE来解决,通过在构建时使用checkstyle插件也可以防止不合规的代码迁入到仓库,从源头上保证代码样式的一致性。

最后,希望这份Java开发手册可以持续改进,吸纳百家之长,成为每个入门程序员必看的手册。


更多精彩洞见,请关注微信公众号:思特沃克

Share