高级 Angular 组件模式 (6)


06 Use

原文: Use

[Render Props](https://reactjs.org/docs/render-props.html)最近在React社区中引起了轰动,但是与之类似的模式在Angular中似乎并没有得到太多关注。我在之前写的文章提及过,TemplateRefs就是Angular中的Render Props,同时我会在这篇文章中列举一个简单易用的例子。

Note: TemplateRef是一个类名而<ng-template>是一个html标签,它们本质上是相同的。不过你可能会在项目中更频繁地使用<ng-template>,但是在网上你可以很容易的搜索到关于TemplateRef的知识,因为<ng-template>会给你提供很多html5中的<template>标签的信息。

我们已有的实现中,使用自定义内容指令(content directives)。当组件作者提前了解使用该toggle组件的父组件所需要的状态时,那么它将会正常的运作。但是如果父组件所需要的状态并不在我们的设想之内,我们该怎么办?

目标

toggle组件的状态直接提供给父组件,同时允许父组件提供相应的渲染视图(view)。

实现

<ng-template>组件可以完美地解决问题。

1. Toggle 组件

<toggle>组件能够通过ContentChild装饰器得到关于<ng-template>的引用,之后会赋予模板在渲染时所需要的状态,代码如下:

1
2
3
<ng-container
*ngTemplateOutlet="layoutTemplate; context: { on: this.on, toggle: this.toggle, fns: { toggle: this.toggle } }">
</ng-container>

这里<ng-container>被当做一个占位符来使用,之后你可以使用*ngTemplateOutlet指令来填充它,layoutTemplate变量指代的是需要被渲染的模板,context对象包含的键值对会作为组件状态注入layoutTemplate中。

2. 父组件

toggle组件中传入的状态是通过let关键字在父组件的<ng-template>标签上显示声明的。

let关键字的使用方式类是这样的:let-templatevar="inputvar"templatevar指代在<ng-template>标签中,关联组件状态值的变量名,而inputvar指代使用<toggle>组件的模板作用域中的变量名。

这种语法会有效地避免命名冲突,比如在父组件作用域中已经有一个inputvar变量了。

成果

stackblitz演示地址

译者注

这种组件设计模式按我个人的理解,其实是依赖倒置原则在视图渲染层的一种延伸,为什么这么说呢?是因为通常情况下子组件视图的渲染逻辑取决于传入的props状态和自身提供的模板,这在大多数情况下不会造成任何困扰,但是当我们无法在提前得知我们需要渲染什么的时候,这个问题就会变得十分棘手。

一种解决方法,我们可以使用条件渲染指令,根据传入的状态来判定组件渲染的状态,这种解决方法在情况比较少的情况下是可以解决问题的,但是当情况数量十分庞大的情况下,增加过多的条件判定会致使子组件的模板代码量剧增,同时降低性能,因为每次渲染都会进行若干次条件逻辑判断。

除了上面的解决方法,就是使用正文中所提及的模式了,这种模式将子组件视图的渲染逻辑倒置为子组件仅仅声明模板中所会使用的状态变量,对于这些变量和模板的注入工作,全权赋予父组件,因此会使子组件的复用性和可测试性大大提高。

正文中仅列举了一个简单的例子中,我这里在简单提及一个实际工作可能会用到的例子,就是表单校验的错误提示组件,一般前端组件设计但凡涉及表单,都会是十分复杂的,更不用说校验这种灵活性很高的功能了。

为了适应表单校验的灵活性,我们使用这种模式会事半功倍,提供校验信息的组件仅仅声明渲染表单错误提示信息需要设计的状态变量即可,比如dirtytouched等等,对于错误信息的文案及样式,统统交由错误提示组件的使用者完成。

more

小心 Angular 中的单例 Service


原文: Angular Services do NOT have to be Singletons

你可能知道,当我们通过@NgModule()装饰器来声明一个service时,它将符合单例模式,同时还意味着它与整个应用的生命周期保持一致。比如:

1
2
3
4
5
6
export class AdminService {
data = Array(10000).fill(dummy);
}
@NgModule({
providers: [AdminService, AdminDataService]
})

我们在刚开始接触Angular的时候,总是不计后果的将所有service都使用@NgModule()来声明,这将会造成一个不易发现的问题:

You are not releasing memory.

在上面的例子中,尽管你不再需要这些内存中储存的数据,但是让我们停下来仔细想一想,我们真的需要将一个service声明为单例的吗?

比如,在我们整个应用中,我们会有一个管理区域需要呈现大量的表格数据(同时这些数据只在这个管理区域展现),这些数据会储存在内存中。在这种情况下,我们没有必要将这个service声明为单例的,因为我们不需要缓冲层来缓存这些数据以供应用中的其他模块使用。

进一步讲,当前我们仅仅是想使这些表格数据在多个component之间共享,同时将数据与service中的多个helper方法耦合起来。所以我们完全可以直接使用@Component()装饰器来声明service,这样它就会成为一个非单例service,如下:

1
2
3
4
@Component({
selector: 'admin-tab',
providers: [AdminService, AdminDataService]
})

这样做的好处是,当Angular注销组件实例时,Angular将同时注销与之绑定的service实例,y也会释放那些用来储存数据的内存。

OnDestroy 钩子函数

许多开发者也许不知道非单例servicengOnDestroy()生命周期,所以你也可以在这个生命周期中进行一些销毁逻辑代码的编写,比如:

1
2
3
4
5
export class AdminService implements OnDestroy {
ngOnDestroy() {
// Clean subscriptions, intervals, etc
}
}

另外,如果我们调用NgModuleRef.destroy()或者PlatformRef.destroy(),单例servicengOnDestroy钩子函数也会被[执行]。(https://github.com/angular/angular/blob/674c3def319e2c444823319ae43394d46f3973b7/packages/core/src/view/ng_module.ts#L199-L204)。

译者注

之所以翻译了这篇文章,是因为今天在整理项目代码的时候,偶然发现了这个问题,虽然我使用Angular也有一段时间了,但是依然将很多没有必要声明在NgModule中的服务以单例模式的方式声明了。文章中指出的问题确实是一个重要但又难以发现的问题。

大体总结一下Angular中声明service的不同方式和应用场景。

使用@Component

这时service与组件本身生命周期保持一致,非单例,适合声明一些需要暂存数据的工具类或者仅在某个或某几个组件中需要缓存数据的状态管理类service

使用@NgModuleproviders

这时service与应用本身生命周期保持一致(非懒加载),单例,适合声明一些需要在全局缓存数据的状态管理类service

但是有一个特例,懒加载模块中的service是会在模块加载时重新创建一个实例的,懒加载模块中均会注入后创建的service实例,因此懒加载模块与非懒加载模块间的service非单例。

使用forRoot

使用forRoot可以保证当前模块即使是懒加载模块,在加载时也不会重新创建一个新的service实例,因为懒加载模块在加载时,会临时创建一个从属于根injector的子injector,根据Angular中的依赖注入流程,当尝试通过一个子injector中注入不存在的实例对象时,会尝试向父级injector获取,因此最终可保证该service在应用任何地方被注入均是单例。

关于官方文档的介绍,可以参考ProvidersSingleton Services

more

30分钟理解GraphQL核心概念


写在前面

在上一篇文章RPC vs REST vs GraphQL中,对于这三者的优缺点进行了比较宏观的对比,而且我们也会发现,一般比较简单的项目其实并不需要GraphQL,但是我们仍然需要对新的技术有一定的了解和掌握,在新技术普及时才不会措手不及。

这篇文章主要介绍一些我接触GraphQL的这段时间,觉得需要了解的比较核心的概念,比较适合一下人群:

  • 听说过GraphQL的读者,想深入了解一下
  • 想系统地学习GraphQL的读者
  • 正在调研GraphQL技术的读者

这些概念并不局限于服务端或者是客户端,如果你熟悉这些概念,在接触任意使用GraphQL作为技术背景的库或者框架时,都可以通过文档很快的上手。

如果你已经GraphQL应用于了实际项目中,那么这篇文章可能不适合你,因为其中并没有包含一些实践中的总结和经验,关于实践的东西我会在之后再单另写一篇文章总结。

什么是GraphQL

介绍GraphQL是什么的文章网上一搜一大把,篇幅有长有短,但是从最核心上讲,它是一种查询语言,再进一步说,是一种API查询语言。

这里可能有的人就会说,什么?API还能查?API不是用来调用的吗?是的,这正是GraphQL的强大之处,引用官方文档的一句话:

ask what exactly you want.

我们在使用REST接口时,接口返回的数据格式、数据类型都是后端预先定义好的,如果返回的数据格式并不是调用者所期望的,作为前端的我们可以通过以下两种方式来解决问题:

  • 和后端沟通,改接口(更改数据源)
  • 自己做一些适配工作(处理数据源)

一般如果是个人项目,改后端接口这种事情可以随意搞,但是如果是公司项目,改后端接口往往是一件比较敏感的事情,尤其是对于三端(web、andriod、ios)公用同一套后端接口的情况。大部分情况下,均是按第二种方式来解决问题的。

因此如果接口的返回值,可以通过某种手段,从静态变为动态,即调用者来声明接口返回什么数据,很大程度上可以进一步解耦前后端的关联。

在GraphQL中,我们通过预先定义一张Schema和声明一些Type来达到上面提及的效果,我们需要知道:

  • 对于数据模型的抽象是通过Type来描述的
  • 对于接口获取数据的逻辑是通过Schema来描述的

这么说可能比较抽象,我们一个一个来说明。

Type

对于数据模型的抽象是通过Type来描述的,每一个Type有若干Field组成,每个Field又分别指向某个Type。

GraphQL的Type简单可以分为两种,一种叫做Scalar Type(标量类型),另一种叫做Object Type(对象类型)

Scalar Type

GraphQL中的内建的标量包含,StringIntFloatBooleanEnum,对于熟悉编程语言的人来说,这些都应该很好理解。

值得注意的是,GraphQL中可以通过Scalar声明一个新的标量,比如:

  • prisma(一个使用GraphQL来抽象数据库操作的库)中,还有DateTimeID这两个标量分别代表日期格式和主键
  • 在使用GraphQL实现文件上传接口时,需要声明一个Upload标量来代表要上传的文件

总之,我们只需要记住,标量是GraphQL类型系统中最小的颗粒,关于它在GraphQL解析查询结果时,我们还会再提及它。

Object Type

仅有标量是不够的抽象一些复杂的数据模型的,这时候我们需要使用对象类型,举个例子(先忽略语法,仅从字面上看):

1
2
3
4
5
type Article {
id: ID
text: String
isPublished: Boolean
}

上面的代码,就声明了一个Article类型,它有3个Field,分别是ID类型的id,String类型的text和Boolean类型的isPublished。

对于对象类型的Field的声明,我们一般使用标量,但是我们也可以使用另外一个对象类型,比如如果我们再声明一个新的User类型,如下:

1
2
3
4
type User {
id: ID
name: String
}

这时我们就可以稍微的更改一下关于Article类型的声明代码,如下:

1
2
3
4
5
6
type Article {
id: ID
text: String
isPublished: Boolean
author: User
}

Article新增的author的Field是User类型, 代表这篇文章的作者。

总之,我们通过对象模型来构建GraphQL中关于一个数据模型的形状,同时还可以声明各个模型之间的内在关联(一对多、一对一或多对多)。

Type Modifier

关于类型,还有一个较重要的概念,即类型修饰符,当前的类型修饰符有两种,分别是ListRequired,它们的语法分别为[Type]Type!, 同时这两者可以互相组合,比如[Type]!或者[Type!]或者[Type!]!(请仔细看这里!的位置),它们的含义分别为:

  • 列表本身为必填项,但其内部元素可以为空
  • 列表本身可以为空,但是其内部元素为必填
  • 列表本身和内部元素均为必填

我们进一步来更改上面的例子,假如我们又声明了一个新的Comment类型,如下:

1
2
3
4
5
type Comment {
id: ID!
desc: String,
author: User!
}

你会发现这里的ID有一个!,它代表这个Field是必填的,再来更新Article对象,如下:

1
2
3
4
5
6
7
type Article {
id: ID!
text: String
isPublished: Boolean
author: User!
comments: [Comment!]
}

我们这里的作出的更改如下:

  • id字段改为必填
  • author字段改为必填
  • 新增了comments字段,它的类型是一个元素为Comment类型的List类型

最终的Article类型,就是GraphQL中关于文章这个数据模型,一个比较简单的类型声明。

Schema

现在我们开始介绍Schema,我们之前简单描述了它的作用,即它是用来描述对于接口获取数据逻辑的,但这样描述仍然是有些抽象的,我们其实不妨把它当做REST架构中每个独立资源的uri来理解它,只不过在GraphQL中,我们用Query来描述资源的获取方式。因此,我们可以将Schema理解为多个Query组成的一张表。

这里又涉及一个新的概念Query,GraphQL中使用Query来抽象数据的查询逻辑,当前标准下,有三种查询类型,分别是query(查询)mutation(更改)subscription(订阅)

Note: 为了方便区分,Query特指GraphQL中的查询(包含三种类型),query指GraphQL中的查询类型(仅指查询类型)

Query

上面所提及的3中基本查询类型是作为Root Query(根查询)存在的,对于传统的CRUD项目,我们只需要前两种类型就足够了,第三种是针对当前日趋流行的real-time应用提出的。

我们按照字面意思来理解它们就好,如下:

  • query(查询):当获取数据时,应当选取Query类型
  • mutation(更改):当尝试修改数据时,应当使用mutation类型
  • subscription(订阅):当希望数据更改时,可以进行消息推送,使用subscription类型

仍然以一个例子来说明。

首先,我们分别以REST和GraphQL的角度,以Article为数据模型,编写一系列CRUD的接口,如下:

Rest 接口

1
2
3
4
5
GET /api/v1/articles/
GET /api/v1/article/:id/
POST /api/v1/article/
DELETE /api/v1/article/:id/
PATCH /api/v1/article/:id/

GraphQL Query

1
2
3
4
5
6
7
8
9
10
Query {
articles(): [Article!]!
article(id: Int): Article!
}

mutation {
createArticle(): Article!
updateArticle(id: Int): Article!
deleteArticle(id: Int): Article!
}

对比我们较熟悉的REST的接口我们可以发现,GraphQL中是按根查询的类型来划分Query职能的,同时还会明确的声明每个Query所返回的数据类型,这里的关于类型的语法和上一章节中是一样的。需要注意的是,我们所声明的任何Query都必须是Root Query的子集,这和GraphQL内部的运行机制有关。

例子中我们仅仅声明了Query类型和Mutation类型,如果我们的应用中对于评论列表有real-time的需求的话,在REST中,我们可能会直接通过长连接或者通过提供一些带验证的获取长连接url的接口,比如:

1
POST /api/v1/messages/

之后长连接会将新的数据推送给我们,在GraphQL中,我们则会以更加声明式的方式进行声明,如下

1
2
3
4
5
6
7
8
subscription {
updatedArticle() {
mutation
node {
comments: [Comment!]!
}
}
}

我们不必纠结于这里的语法,因为这篇文章的目的不是让你在30分钟内学会GraphQL的语法,而是理解的它的一些核心概念,比如这里,我们就声明了一个订阅Query,这个Query会在有新的Article被创建或者更新时,推送新的数据对象。当然,在实际运行中,其内部实现仍然是建立于长连接之上的,但是我们能够以更加声明式的方式来进行声明它。

Resolver

如果我们仅仅在Schema中声明了若干Query,那么我们只进行了一半的工作,因为我们并没有提供相关Query所返回数据的逻辑。为了能够使GraphQL正常工作,我们还需要再了解一个核心概念,Resolver(解析函数)

GraphQL中,我们会有这样一个约定,Query和与之对应的Resolver是同名的,这样在GraphQL才能把它们对应起来,举个例子,比如关于articles(): [Article!]!这个Query, 它的Resolver的名字必然叫做articles

在介绍Resolver之前,是时候从整体上了解下GraphQL的内部工作机制了,假设现在我们要对使用我们已经声明的articles的Query,我们可能会写以下查询语句(同样暂时忽略语法):

1
2
3
4
5
6
7
8
9
10
11
12
13
Query {
articles {
id
author {
name
}
comments {
id
desc
author
}
}
}

GraphQL在解析这段查询语句时会按如下步骤(简略版):

  • 首先进行第一层解析,当前QueryRoot Query类型是query,同时需要它的名字是articles
  • 之后会尝试使用articlesResolver获取解析数据,第一层解析完毕
  • 之后对第一层解析的返回值,进行第二层解析,当前articles还包含三个子Query,分别是idauthorcomments
    • id在Author类型中为标量类型,解析结束
    • author在Author类型中为对象类型User,尝试使用UserResolver获取数据,当前field解析完毕
    • 之后对第二层解析的返回值,进行第三层解析,当前author还包含一个Query, name,由于它是标量类型,解析结束
    • comments同上…

我们可以发现,GraphQL大体的解析流程就是遇到一个Query之后,尝试使用它的Resolver取值,之后再对返回值进行解析,这个过程是递归的,直到所解析Field的类型是Scalar Type(标量类型)为止。解析的整个过程我们可以把它想象成一个很长的Resolver Chain(解析链)。

这里对于GraphQL的解析过程只是很简单的概括,其内部运行机制远比这个复杂,当然这些对于使用者是黑盒的,我们只需要大概了解它的过程即可。

Resolver本身的声明在各个语言中是不一样的,因为它代表数据获取的具体逻辑。它的函数签名(以js为例子)如下:

1
2
3
function(parent, args, ctx, info) {
...
}

其中的参数的意义如下:

  • parent: 当前上一个Resolver的返回值
  • args: 传入某个Query中的函数(比如上面例子中article(id: Int)中的id
  • ctx: 在Resolver解析链中不断传递的中间变量(类似中间件架构中的context)
  • info: 当前Query的AST对象

值得注意的是,Resolver内部实现对于GraphQL完全是黑盒状态。这意味着Resolver如何返回数据、返回什么样的数据、从哪返回数据,完全取决于Resolver本身,基于这一点,在实际中,很多人往往把GraphQL作为一个中间层来使用,数据的获取通过Resolver来封装,内部数据获取的实现可能基于RPC、REST、WS、SQL等多种不同的方式。同时,基于这一点,当你在对一些未使用GraphQL的系统进行迁移时(比如REST),可以很好的进行增量式迁移。

总结

大概就这么多,首先感谢你耐心的读到这里,虽然题目是30分钟熟悉GraphQL核心概念,但是可能已经超时了,不过我相信你对GraphQL中的核心概念已经比较熟悉了。但是它本身所涉及的东西远远比这个丰富,同时它还处于飞速的发展中。

最后我尝试根据这段时间的学习GraphQL的经验,提供一些进一步学习和了解GraphQL的方向和建议,仅供参考:

想进一步了解GraphQL本身

我建议再仔细去官网,读一下官方文档,如果有兴趣的话,看看GraphQL的spec也是极好的。这篇文章虽然介绍了核心概念,但是其他一些概念没有涉及,比如Union、Interface、Fragment等等,这些概念均是基于核心概念之上的,在了解核心概念后,应当会很容易理解。

偏向服务端

偏向服务端方向的话,除了需要进一步了解GraphQL在某个语言的具体生态外,还需要了解一些关于缓存、上传文件等特定方向的东西。如果是想做系统迁移,还需要对特定的框架做一些调研,比如graphene-django。

如果是想使用GraphQL本身做系统开发,这里推荐了解一个叫做prisma的框架,它本身是在GraphQL的基础上构建的,并且与一些GraphQL的生态框架兼容性也较好,在各大编程语言也均有适配,它本身可以当做一个ORM来使用,也可以当做一个与数据库交互的中间层来使用。

偏向客户端

偏向客户端方向的话,需要进一步了解关于graphql-client的相关知识,我这段时间了解的是apollo,一个开源的grapql-client框架,并且与各个主流前端技术栈如Angular、React等均有适配版本,使用感觉良好。

同时,还需要了解一些额外的查询概念,比如分页查询中涉及的Connection、Edge等。

大概就这么多,如有错误,还望指正。

more

RPC vs REST vs GraphQL


写在前面

最近2周的时间由于工作不忙,一直在看有关GraphQL的东西,前后端均有涉及,由于我之前做过后端开发,当时实现的接口的大体是符合RPC风格的接口。后来转做了前端开发,从实现接口者变成了调用接口者,接触最多的当属REST风格的接口。因此在这段学习GraphQL的过程中,并且也尝试使用它以全栈的角度做了一个小项目,在这个过程中,一直在思考它对比前两者在API设计的整体架构体系中的各个指标上,孰优孰劣。

其实在使用和学习的过程中,有很多文章都对比过它们的异同,但是大部分文章并没有从一个相对客观的角度来对比,更多是为了突显一个的优点而刻意指出另外一个的缺点。这让我想到一句话,脱离业务情景谈技术就是耍流氓。

昨天订阅的GraphQL Weekly中推送的一个视频正好是讲关于它们这三者的,于是就点进去看了看,发现质量还是不错的,于是就想整理出来,分享给大家。

原视频地址(油管地址,自备梯子):这里

如果没有梯子的话直接看我整理的东西也可以,我觉的应该都覆盖到视频中所讲的重点内容了。

当然,这些内容如果分开来讲,每一块内容所涉及的东西都够写一本书了,这里仅仅是简单归纳和整理,从宏观的角度来对比它们的异同,从而能够在日后面临技术选型时,有一个更佳明确的决策方向。

RPC

先简单介绍下RPC,它是Remote Procedure Call(远程过程调用)的简称。一般基于RPC协议所设计的接口,是基于网络采用客户端/服务端的模式完成调用接口的。

优点

  • 简单并且易于理解(面向开发者)
  • 轻量级的数据载体
  • 高性能

缺点

  • 对于系统本身耦合性高
  • 因为RPC本身很简单、轻量,因此很容易造成 function explosion

关于RPC的优点其实很好理解,就是因为它性能高同时又很简单,但是我认为这是对于接口提供者来讲的(因为它的高耦合性)。

但是如果从接口调用者的角度来看,高耦合性就变成了缺点,因为高耦合意味着调用者必须要足够了解系统本身的实现才能够完成调用,比如:

  • 调用者需要知道所调用接口的函数名、参数格式、参数顺序、参数名称等等
  • 如果接口提供者(server)要对接口做出一些改变,很容易对接口调用者(client)造成breaking change(违背开闭原则)
  • 一般RPC所暴露接口仅仅会暴露函数的名称和参数等信息,对于函数之间的调用关系无法提供,这意味着调用者必须足够了解系统,从能够知道如何正确的调用这些接口,但是对于接口调用者往往不需要了解过多系统内部实现细节

关于上面的第二点,为了减少breaking change,我之前实现接口的时候一般都会引入版本的概念,就是在暴露接口的方法名中加入版本号,一开始效果确实不错,但是随后就不知不觉的形成了function explosion,和视频中主讲人所举例的例子差不多,贴一下视频中的截图感受一波:

REST

当前REST风格的API架构方式已经成了主流解决方案了,相比较RPC,它的主要不同之处在于,它是对于资源(Resource)的模型化而非步骤(Procedure)。

优点

  • 对于系统本身耦合性低,调用者不再需要了解接口内部处理和实现细节
  • 重复使用了一些 http 协议中的已定义好的部分状态动词,增强语义表现力
  • API可以随着时间而不断演进

缺点

  • 缺少约束,缺少简单、统一的规范
  • 有时候 payload 会变的冗余(overload),有时候调用api会比较繁琐(chattiness)
  • 有时候需要发送多条请求已获取数据,在网络带宽较低的场景,往往会造成不好的影响

REST的优点基本解决了RPC中存在的问题,就是解耦,从而使得前后端分离成为可能。接口提供者在修改接口时,不容易造成breaking-change,接口调用者在调用接口时,往往面向数据模型编程,而省去了了解接口本身的时间成本。

但是,我认为REST当前最大的问题在于虽然它利用http的动词约束了接口的暴露方式,同时增强了语义,但是却没有约束接口如何返回数据的最佳实践,总让人感觉只要是返回json格式的接口都可以称作REST。

我在实际工作中,经常会遇到第二条缺点所指出的问题,就是接口返回的数据冗余度很高,但是却缺少我真正需要的数据,因此不得已只能调用其他接口或者直接和后端商议修改接口,并且这种问题会在web端和移动端共用一套接口中被放大。

当前比较好的解决方案就是规范化返回数据的格式,比如json-schema或者自己制定的规范。

GraphQL

GraphQL是近来比较热门的一个技术话题,相比REST和RPC,它汲取了两者的优点,即不面向资源,也不面向过程,而是面向数据查询(ask for exactly what you want)。

同时GraphQL本身需要使用强类型的Schema来对数据模型进行定义,因此相比REST它的约束性更强。

优点

  • 网络开销低,可以在单一请求中获取REST中使用多条请求获取的资源
  • 强类型Schema(约束意味着可以根据规范形成文档、IDE、错误提示等生态工具)
  • 特别适合状数据结构的业务场景(比如好友、流程、组织架构等系统)

缺点

  • 本身的语法相比较REST和RPC均复杂一些
  • 实现方面需要配套 Caching 以解决性能瓶颈
  • 对于 API 的版本控制当前没有完善解决方案(社区的建议是不要使API版本化)
  • 仍然是新鲜事物,很多技术细节仍然处于待验证状态

鉴于GraphQL这两个星期我也仅仅是做了一些简单地使用和了解,仅仅说一下感受。

首先值得肯定的是,在某些程度上确实解决了REST的缺点所带来的问题,同时配套社区建议的各种工具和库,相比使用REST风格,全栈开发体验上升一个台阶。

但是这个看起来很好的东西为什么没有火起来呢?我觉的最主要的原因是因为GraphQL所带来的好处,大部分是对于接口调用者而言的,但是实现这部分的工作却需要接口提供者来完成。

同时GraphQL的最佳实践场景应当是类似像Facebook这样的网站,业务逻辑模型是图状数据结构,比如社交。如果在一些业务逻辑模型相对简单的场景,使用GraphQL确实不如使用REST来得简单明了、直截了当。

另外一方面是GraphQL的使用场景相当灵活,在我自己的调研项目中,我是把它当做一个类似ORM的框架来使用的,在别人的一些文章中,会把它当做一个中间层来做渐进式开发和系统升级。这应当算是另外一个优点。

到底用哪个

下面根据要设计的API类型给予一些技术选型建议。

如果是Management API,这类API的特点如下:

  • 关注于对象与资源
  • 会有多种不同的客户端
  • 需要良好的可发现性和文档

这种情景使用REST + JSON API可能会更好。

如果是Command or Action API,这类API的特点如下:

  • 面向动作或者指令
  • 仅需要简单的交互

这种情况使用RPC就足够了。

如果是Internal Micro Services API,这类API的特点如下:

  • 消息密集型
  • 对系统性能有较高要求

这种情景仍然建议使用RPC

如果是Micro Services API,这类API的特点如下:

  • 消息密集型
  • 期望系统开销较低

这种情景使用RPC或者REST均可。

如果是Data or Mobile API,这类API的特点是:

  • 数据类型是具有图状的特点
  • 希望对于高延迟场景可以有更好的优化

这种场景无疑GraphQL是最好的选择。

写在最后

提供一张表格来总览它们之间在不同指标下的表现:

耦合性 约束性 复杂度 缓存 可发现性 版本控制
RPC(Function) high medium low custom bad hard
REST(Resource) low low low http good easy
GraphQL(Query) medium high medium custom good ???

最后引用人月神话中的观点no silver bullet,在技术选型时需要具体情况具体分析,不过鉴于GraphQL的灵活性,把它与RPC和REST配置使用,也是不错的选择。

more

高级 Angular 组件模式 (4)


04 Avoid Namespace Clashes with Directives

原文: Avoid Namespace Clashes with Directives

提示

在同一个html元素上绑定多个指令可能会造成命名冲突。

命名冲突不仅存在于指令的选择器之间,同时也会存在于指令的InputsOutputs属性,当这些属性名一样时,Angular并不会进行提示,它会按原本的逻辑正常工作。这种情况有时候是我们希望看到的,有些时候却不是。

目标

避免存在于绑定在相同元素上的多个指令上的命名冲突。

实现

因为togglewithToggle指令都绑定于<toggle>元素,我们将通过为它们增加一个label属性来说明问题。

首先我们设置一个label属性,比如:

1
<toggle label="some label">

这个label属性的值会同时绑定在每个指令上,如果想要为其中的某个指令单独绑定,只能通过使用prefix(前缀)来实现。

Angular官方提供的规范指南也警示了这一点,当你在使用prefix修饰指令的名称时,也需要注意使用prefix来修饰InputOutput属性的名称。

Note: 当使用Output属性重写原生DOM元素的事件和使用Input属性重写原生元素的属性时,请额外注意,没有任何方式可以获知别人在他们编写的应用或者库中使用的命名,但是你可以很轻易的知道的具体命名的大体规则是什么,并且不要重写它们,除非你有意为之。

增加prefix的一种方式是在每个指令的label属性的装饰器内增加一个字符串参数,如下:

1
2
3
4
5
// In withToggle.directive.ts
@Input('withToggleLabel') label;

// In toggle.directive.ts
@Input('toggleLabel') label;

但是这种解决方案的前提时,你至少能够更改存在命名冲突中的一个或多个指令的源码。如果在两个第三方库中存在命名冲突,这种情况是最棘手的,我们不在这里讨论它们。

成果

https://stackblitz.com/edit/adv-ng-patterns-04-namespace-clashes

译者注

原文中关于最后一段提出的关于在多个第三方库中存在的命名冲突的场景,作者提供做出具体的解决方案,我在这里简单分享一下自己对于这种情况的解决方案:

通常这种情况比较少见,但是万一存在这种情况,我们可以通过创建一个新的wrapper指令来封装第三方指令,wrapper指令提供与第三方指令一样的接口属性,但是因为我们对于wrapper指令有绝对的控制权,我们可以提供统一的prefix来修饰这些接口属性,从而达到解决冲突的效果。

more

高级 Angular 组件模式 (5)


05 Handle Template Reference Variables with Directives

原文: Handle Template Reference Variables with Directives

在之前的例子中,已经出现多次使用template reference variable(模板引用变量)的场景,现在让我们来深入研究如何通过使用模板引用变量来关联某个具体指令。

目标

在视图模板内,获取一个指令的引用。

实现

模板引用变量是获取某个元素、组件或者指令引用的一种方式,这个引用可以在当前的视图模板中的任何地方使用。它们通常是以#baseToggle或者#myToggle="toggle"的语法声明的。一旦声明,在视图模板的任何地方就可以使用它。

Note: 请注意作用域的问题,如果你使用<ng-template>或者是一个结构性指令,比如*ngIf或者*ngFor,它会在这个模板上创建一个新的作用域,之后在其内部声明的模板引用变量无法在该模板作用域以外使用。

模板引用变量的解析顺序通常为:

  1. 一个指令或者组件通过它自身的exportAs属性,比如#myToggle="toggle"
  2. 声明于以自定义标签存在的组件,比如<toggle-on #toggleOn></toggle-on>
  3. 原生html元素,并且没有任何组件绑定与它,比如<div #someDiv></div>

之后我们来分别看3个例子。

指令与exportAs

指令可以在它的元数据中声明exportAs属性,这个属性表示它被这个模板引用变量所标识,如下:

1
2
3
4
5
6
// toggle.directive.ts
@Directive({
selector: '[toggle]',
exportAs: 'toggle',
})
export class ToggleDirective { ... }

之后我们可以在视图模板中直接使用toggle来获取指令的引用,如下:

1
2
3
// app.component.html
<div toggle #myTemplateRefVar="toggle"></div>
// myTemplateRefVar is the ToggleDirective

组件

对于每一个html元素,只会有一个组件与之对应。当一个组件绑定于一个元素时,那么声明的模板引用变量将会被解析为当前元素上所绑定的组件,比如:

1
2
3
// app.component.html
<toggle-on #toggleOn></toggle-on>
// toggleOn is the ToggleOnComponent

HTML元素

如果没有组件与元素绑定,模板引用变量会指向当前这个html元素。

1
2
3
// app.component.html
<div #someDiv></div>
// someDiv is an HTMLDivElement

成果

Note: 在stackblitz中,我通过打印模板引用变量所指向的类的名字(constructor.name)来演示它所代表的引用。

https://stackblitz.com/edit/adv-ng-patterns-05-template-ref-variables

译者注

这篇文章作者关于模板引用变量,仅仅介绍了关于如何声明和在视图模板中如何使用,我在这里再补充一些,如何在组件或者指令类的内部使用。

在类内部获取模板引用变量所指向的引用是通过使用ViewChild装饰器完成的,比如上述文章中的第二个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Component({
selector: 'my-app',
template: `
<div #myDiv></div>
`,
})
export class AppComponent {
@ViewChild('myDiv')
myDiv: ElementRef;

ngAfterViewInit() {
console.log(this.myDiv);
}
}

这里的myDiv即指向当前模板引用变量所指向的html元素。

Note: 在类中获取模板引用变量所指向的引用时,请格外注意你期望获取的引用类型,在例子中,我们期望获取html元素,因此这里的引用类型是ElementRef,如果是指令或者组件,则分别要对应其类型的Type

more

javascript原型链-review


写在前面

虽然现在es8都已经在预发布阶段了,但是无论发布到es几,其本身的运作原理都是一样的。

首先祭上一张图, 这张图主要描述了以下的关系,如果觉的这里的说明过于复杂可以直接看最后一段

简单说明

关于function(class)A和它的原型之间的关系

A.prototype.constructorA等价

关于function(class)A的实例a与它的原型之间的关系

a.__proto__A.prototype等价

在上面两个等价条件的基础上,就可以很容易得到

a.__proto__.prototype.constructorA等价

这是一般的类和对象实例之间的原型继承关系。

在此基础上,对于ObjectFunction还有一些特殊的关系。

关于function(class)AFunction之间的关系

A.__proto__Function.prototype等价

关于function(class)A的原型和Object之间的关系

A.prototype.__proto__Object.prototype等价

关于Function的原型和Object之间的关系

Function.prototype.__proto__Object.prototype等价

所以也可以很容易知道

A.__proto__.__proto__Object.prototype等价

Object对象比较特殊,因为它是所有对象的根,所以约定它的原型所指向的原型对象为空

Object.prototype.__proto__null等价

同时javascript中一切皆为对象,但Object本身是一个构造函数,因此它本身的原型对象指向Function.prototype

Object.__proto__Function.prototype

总结

所以无论是es5风格的继承还是es6风格的继承语法,原型链的形成是都是通过__proto__prototype描述的,举个例子,这里使用es6, 即:

如果有:

1
2
class B extends A {}
const a = new B()

则有:

  • a.__proto__ === B.prototype
  • B.prototype.constructor.__proto__ === B.__proto__
  • B.__proto__ === A
  • A.prototype.constructor.__proto === A.__proto__
  • A.__proto__ === Function.prototype
  • Function.prototype.__proto__ === Object.prototype
  • Object.prototype.__proto__ === null

其他的以此类推。

more

【译】Understanding SOLID Principles - Liskov Substitution Principle


Understanding SOLID Principles: Liskov Substitution Principle

这是理解SOLID原则,关于里氏替换原则为什么提倡我们面向抽象层编程而不是具体实现层,以及为什么这样可以使代码更具维护性和复用性。

什么是里氏替换原则

Objects should be replaceable with instances of their subtypes without altering the correctness of that program.

某个对象实例的子类实例应当可以在不影响程序正确性的基础上替换它们。

这句话的意思是说,当我们在传递一个父抽象的子类型时,你需要保证你不会修改任何关于这个父抽象的行为和状态语义。

如果你不遵循里氏替换原则,那么你可能会面临以下问题:

  • 类继承会变得很混乱,因此奇怪的行为会发生
  • 对于父类的单元测试对于子类是无效的,因此会降低代码的可测试性和验证程度

通常打破这条原则的情况发生在修改父类中在其他方法中使用的,与当前子类无关联的内部或者私有变量。这通常算得上是一种对于类本身的一次潜在攻击,而且这种攻击可能是你在不经意间自己发起的,而且不仅在子类中。

反面例子

让我们通过一个反面例子来演示这种修改行为和它所产生的后果。比如,我们有一个关于Store的抽象类和它的实现类BasicStore,这个类会储存一些消息在内存中,直到储存的个数超过每个上限。客户端代码的实现也很简单明了,它期望通过调用retrieveMessages就可以获取到所有储存的消息。

代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
interface Store {
store(message: string);
retrieveMessages(): string[];
}

const STORE_LIMIT = 5;

class BasicStore implements Store {
protected stash: string[] = [];
protected storeLimit: number = STORE_LIMIT;

store(message: string) {
if (this.storeLimit === this.stash.length) {
this.makeMoreRoomForStore();
}
this.stash.push(message);
}

retrieveMessages(): string[] {
return this.stash;
}

makeMoreRoomForStore(): void {
this.storeLimit += 5;
}
}

之后通过继承BasicStore,我们又创建了一个新的RotatingStore实现类,如下:

1
2
3
4
5
class RotatingStore extends BasicStore {
makeMoreRoomForStore() {
this.stash = this.stash.slice(1);
}
}

注意RotatingStore中覆盖父类makeMoreRoomForStore方法的代码以及它是如何隐蔽地改变了父类BasicStore关于stash的状态语义的。它不仅修改了stash变量,还销毁了在程序进程中已储存的消息已为将来的消息提供额外的空间。

在使用RotatingStore的过程中,我们会遇到一些奇怪的现象,这正式由于RotatingStore本身产生的,如下:

1
2
3
4
5
6
7
8
9
10
11
const st: Store = new RotatingStore()

st.store("hello")
st.store("world")
st.store("how")
st.store("are")
st.store("you")
st.store("today")
st.store("sir?")

st.retrieveMessages() // 一些消息丢失了

一些消息会无故消失,当前这个类的表现逻辑与所有消息均可以被取出的基本需求不一致。

如何实践里氏替换原则

为了避免这种奇怪现象的发生,里氏替换原则推荐我们通过在子类中调用父类的公有方法来获取一些内部状态变量,而不是直接使用它。这样我们就可以保证父类抽象中正确的状态语义,从而避免了副作用和非法的状态转变。

它也推荐我们应当尽可能的使基本抽象保持简单和最小化,因为对于子类来说,有助于提供父类的扩展性。如果一个父类是比较复杂的,那么子类在覆盖它的时候,在不影响父类状态语义的情况下进行扩展绝非易事。

对于内部系统做可行的后置条件检查也是一个不错的方式,这种检查通常会验证是否子类会搅乱一些关键代码的运行路径(译者注:也可以理解为状态语义),但是我本身对这个实践并没有太多的经验,所以无法给予具体的例子。

代码评论也可以一定程度上给予好的帮助。当你在开发一些你可能无意间做出一些对已有系统的破坏,但是你的同事可能会很容易地发现这些(当局者迷旁观者清)。软件设计保持一致性是一件十分重要的事情,因此应当尽早、尽可能多地查明那些对对象继承链作出潜在修改的代码。

最后,在单一职责原则中,我们曾提及,考虑使用组合模式来替换继承模式

总结

正如你所看到的,在开发软件时,我们往往需要额外花一些努力和精力来使它变得更好。将这些原则牢记于心,理解它们所存在的意义以及它们想要解决的问题,这样会使你的工作变得更加容易、更具条理性,但是同时记住,这并不是一件容易的事,相反,你应当在构思软件时,花相当多的事件思考如何更好地实践这些原则。

试着让自己设计的软件系统具备可适应性,这种适应性可以抵御各种不利的变化以及潜在的错误,这样自然而然地可以使你少加班和早回家(译者注:看来加班是每个程序员都要面临的问题啊)

译者注

这是SOLID原则中我所接触和了解较少的一个原则,但经过仔细思考后,发现其实我们还是经常会在实际工作中运用它的。

在许多面向相对的编程语言中,关于对象的继承机制中,都会提供一些内部变量和状态的修饰符,比如public(公有)protect(保护)private(私有),关于这些修饰符本身的异同这里不再赘述,我想说的是,这些修饰符存在必然有它存在的意义,一定要在实际工作中,使用它们。之前做java后端时,经常在公司的项目的历史代码中发现,很少使用protectprivate对类内部的方法和变量做约束,可见当时的编写者并没有对类本身的职能有一个清晰的认识,又或者是随着时间一步步迭代出来的结果。

那么问题来了,一些静态语言有这些修饰符,但是像javascript这种鸭子类型语言怎么办呢?其实没有必要担心,最早开始学前端的时候,这个问题我就问过自己无数次,javascript虽然没有这些修饰符,但是我们可以通过别的方式来达到类似的效果,或者使用typescript

除了在编程语言层面,在前端实际工作中,你可能会听到一个叫作immutable的概念,这个概念我认为也是里氏替换原则的一直延伸。因为当前的前端框架一般提倡的理念均是f(state) => view,即数据状态代表视图,而数据状态本身由于javascript动态语言的特性,很容易会在不经意间被修改,一旦存在这种修改,视图中便会产生一些意想不到的问题,因此immutable函数式的概念才会在前段时间火起来。

写在最后

经过这五篇文章,我们来分别总结一下这五条基本原则以及它们带来的好处:

  • 单一职责原则:提高代码实现层的内聚度,降低实现单元彼此之间的耦合度
  • 开闭原则:提高代码实现层的可扩展性,提高面临改变的可适应性,降低修改代码的冗余度
  • 里氏替换原则:提高代码抽象层的可维护性,提高实现层代码与抽象层的一致性
  • 接口隔离原则:提高代码抽象层的内聚度,降低代码实现层与抽象层的耦合度,降低代码实现层的冗余度
  • 依赖倒置原则:降低代码实现层由依赖关系产生的耦合度,提高代码实现层的可测试性

可以注意到我这里刻意使用了降低/提高 + 实现层/抽象层 + 特性/程度(耦合度、内聚度、扩展性、冗余度、可维护性,可测试性)这样的句式,之所以这么做是因为在软件工作中,我们理想中的软件应当具备的特点是, 高内聚、低耦合、可扩展、少冗余、可维护、易于测试,而这五个原则也按正确的方向,将我们的软件系统向我们理想中的标准推进。

为了便于对比,特别绘制了下面的表格,希望大家从真正意义上做到将这些原则牢记于心,并付诸于行。

原则 耦合度 内聚度 扩展性 冗余度 维护性 测试性 适应性 一致性
单一职责原则 - + o o + + o o
开闭原则 o o + - + o + o
里氏替换原则 - o o o + o o +
接口隔离原则 - + o - o o + o
依赖倒置原则 - o o - o + + o

Note: +代表增加, -代表降低, o代表持平

more

【译】Understanding SOLID Principles - Interface Segregation Principle


Understanding SOLID Principles: Interface Segregation Principle

这是理解SOLID原则,关于接口隔离原则如何帮助我们创建简单的抽象接口,并使客户端代与接口之间存在的更少的依赖关系。

接口隔离原则是什么

Clients should not be forced to depend on methods that they do not use.

客户端代码不应当被迫依赖于它们不需要的方法。

这个原则本身与单一职责原则关系十分紧密,它意味着当你在定义你的抽象层代码时,不应当在客户端代码在实现抽象逻辑时,暴露一些客户端代码不需要使用或者关心的方法。

进一步说明的话,就是当你有意地在抽象层中暴露的方法时,这意味着所有实现这些抽象逻辑的客户端代码都必须要实现所有的抽象方法,尽管这些方法并不一定都对客户端代码有意义。

将你的接口的保持精简和小颗粒度,并且不要在它们中间增加无用的抽象方法,当你在对新的抽象接口进行命名时,你就会拥有更好的选择,因为你已有了若干小颗粒的命名类型。这样做的意义在于当你在需要提供一个更加大颗粒度的抽象接口时,你可以拥有足够的灵活性来将已有的小颗粒度接口进行组合。

如何实践接口隔离原则

这个例子是关于一个ATM用户界面的抽象接口,这个接口会处理诸如存款请求、取款请求等逻辑,从这个例子中我们会了解到,我们如何对这个接口进行隔离,使其进一步划分为多个独立的、更加具体的若干接口。

首先我们应当有一个工具函数库接口,这个接口会描述我们想要暴露的关于byte操作逻辑的方法,让我们创建这样一个接口,如下

1
2
3
4
5
type ByteUtils interface {
Read(b []byte) (n int, err error) // Read into buffer
Write(b []byte)(n int, err error) // Write into buffer
Trim(b []byte, exclusions string)[]byte // Trim buffer by removing bytes from the exclusion chars
}

它可以正常工作一段时间,但是很快我们就会发现以下两个问题:

  • 它的命名ByteUtils太过于通用,如果我们仅通过命名本身,基本无法获取任何具体的信息
  • 当使用它时,会有一些古怪的感觉,因为当你根据不同的优化场景来按不同逻辑实现trim方法时,你所实现的readwrite几乎没什么差别,但是你却需要重复地实现它们,同时在某些不需要读或者写的场景,仍然需要实现它们。

所以它虽然能够正常工作,但是却不够好。

我们可以通过创建三个更精简、更具体的接口来替代原先通用的接口:

1
2
3
4
5
6
7
8
9
type Reader interface {
Read(b []byte) (n int, err error)
}
type Writer interface {
Write(b []byte)(n int, err error)
}
type Trimmer interface {
Trim(b []byte, exclusions string)[]byte
}

这种颗粒度比较细的接口也可以称为角色接口,因为它们更易于重构和改变,甚至对于已经定义好的角色和目的也可以很容易的进行重新部署和定义。

在这三个基础上,我们可以通过组合它们来获取一个更有关联性的接口列表,比如:

1
2
3
4
5
6
7
8
type ReadWriter interface {
Reader
Writer
}
type TrimReader interface {
Trimmer
Reader
}

这意味客户端代码拥有了可以根据它们各自的需求来组合抽象层接口的灵活性,这样就会避免在实现抽象接口时不必要的麻烦(比如必须要实现某些无用的方法),比如上面的TrimReader的实现并未包含多余的Write方法的声明。

总结

正如你所看到的,通用的接口往往会无意识的将自己和类的实现耦合在了一起,所以你应当尽量的避免这种情况的发生。在设计接口时,你应当时刻提醒自己,我是否需要使用所有在接口中声明的方法呢?如果不是的话,将接口细分为更多个更精简、更具体的接口。

正如甘地曾经说过:

你的行动决定你的习惯,你的习惯决定你的价值,你的价值会决定你的命运。

如果在架构中,你每次都会经过仔细思考,会按照好的模式来进行设计,它将会成为一种习惯,自然慢慢会转变为你的价值或者原则,最终则会成为你的命运,比如成为了一个始终给予完善解决方案的软件架构师。

我的观点是,始终通过挑战自己来变的更好,在某些时刻,你可能会遇到问题,但是往往你可能已经拥有了答案。

Happy coding!

译者注

对于接口隔离原则的理解,我一直觉的它本身其实是单一职责原则的一个扩展,但是它们之间也有细微的不同:

  • 单一职责原则往往面向实现层,比如具体的类或者某个方法
  • 接口隔离原则往往面向抽象层,比如一些抽象类或者抽象方法

所以将两个原则结合起来看的话,可以很容器得到当时提出这两个原则的人的意图,那就是一定要时刻保持简单

在实际工作中,我深知保持简单是一件十分困难的事情,因为工程师本身的使命便是解决问题,而问题往往充满了未知性,而未知性往往代表着改变,这还没有考虑到在项目实施过程中,产品经理天马行空的设计思路,客户们五花八门的需求等等。在这些外界条件下,我们的代码往往会变得复杂无比,充满了各种反模式和冗余代码,最终会使自己陷入无尽的bug修复和维护工作中,怎么还会有时间进行自我提升呢?

所以,为了能够按时下班,为了能够及早回家,为了能够让我们的拥有更多的时间来提升自己和陪伴家人,在软件设计之初,尽可能地针对将来所面临的改变,在设计层面降低软件抽象模块间的耦合程度,在项目实施时,提高每个具体实现模块内部的内聚程度,同时使它们保持简单,这样便是一个好的开始。

more

【译】Understanding SOLID Principles - Open Closed Principle


Understanding SOLID Principles: Open Closed Principle

这是理解SOLID原则,介绍什么是开闭原则以及它为什么能够在对已有的软件系统或者模块提供新功能时,避免不必要的更改(重复劳动)。

开闭原则是什么

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

软件实体(类、模块、函数等)都应当对扩展具有开放性,但是对于修改具有封闭性。

首先,我们假设在代码中,我们已经有了若干抽象层代码,比如类、模块、高阶函数,它们都仅做一件事(还记得单一职责原则吗?),并且都做的十分出色,所以我们想让它们始终处于简洁、高内聚并且好用的状态。

但是另一方面,我们还是会面临改变,这些改变包含范围(译者注:应当是指抽象模块的职责范围)的改变,新功能的增加请求还有新的业务逻辑需求。

所以对于上面我们所拥有的抽象层代码,在长期想让它处于一成不变的状态是不现实的,你不可避免的会针对以上的需要作出改变的需求,增加更多的功能,增加更多的逻辑和交互。在上一篇文章,我们知道,改变会使系统复杂,复杂会促使模块间的耦合性上升,所以我们迫切地需要寻找一种方法能够使我们的抽象模块不仅可以扩大它的职责范围,同时还能够保持当前良好的状态(简洁、高内聚、好用)。

这便是开闭原则存在的意义,它能够帮助我们完美地实现这一切。

如何实践开闭原则

当你需要对已有代码作出一些修改时,请切记以下两点:

  • 保持函数、类、模块当前它们本身的状态,或者是近似于它们一般情况下的状态(即不可修改性)
  • 使用组合的方式(避免使用继承方式)来扩展现有的类,函数或模块,以使它们可能以不同的名称来暴露新的特性或功能

这里关于继承,我们特意增加了一个注释,在这种情况下使用继承可能会使模块之间耦合在一起,同时这种耦合是可避免的,我们通常在一些预先有着良好定义的结构上使用继承。(译者注:这里应该是指,对于我们预先设计好的功能,推荐使用继承方式,对于后续新增的变更需求,推荐使用组合方式)

举个例子(译者注:我对这里的例子做了一些修改,原文中并没有详细的说明)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
interface IRunner {
run: () => void;
}
class Runner implements IRunner {
run(): void {
console.log("9.78s");
}
}

interface IJumper {
jump: () => void;
}
class Jumper implements IJumper {
jump(): void {
console.log("8.95,");
}
}

例子中,我们首先声明了一个IRunner接口,之后又声明了IJumper,并分别实现了它们,并且实现类的职能都是单一的。

假如现在我们需要提供一个既会跑又会跳的对象,如果我们使用继承的方式,可以这么写

1
2
3
class RunnerAndJumper extends Runner {
jump: () => void
}

或者

1
2
3
class RunnerAndJumper extends Jumper {
run: () => void
}

但是使用继承的方式会使这个RunnerAndJumperRunner(或者Jumper)耦合在一起(耦合在一起的原因是因为它的职责不再单一),我们再来用组合的方式试试看,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class RunnerAndJumper {
private runnerClass: IRunner;
private jumperClass: IJumper;
constructor(runner: IRunner, jumper: IJumper) {
this.runnerClass = new runner();
this.jumperClass = new jumper();
}
run() {
this.runnerClass.run();
}
jump() {
this.jumperClass.jump();
}
}

我们在RunnerAndJumper的构造函数中声明两个依赖,一个是IRunner类型,一个是IJumper类型。

最终的代码其实和依赖倒置原则中的例子很像,而且你会发现,RunnerAndJumper类本身并没有与任何别的类耦合在一起,它的职能同样是单一的,它是对一个即会跑又会跳的实体的抽象,并且这里我们还可以使用DI(依赖注入)技术进一步的优化我们的代码,降低它的耦合度。

反思

开闭原则所带来最有用的好处就是,当我们在实现我们的抽象层代码时,我们就可以对未来可能需要作出改变的地方拥有一个比较完整的设想,这样当我们真正面临改变时,我们所对原有代码的修改,更贴近于改变本身,而不是一味的修改我们已有的抽象代码。

在这种情况下,由于我们节省了不必要的劳动和时间,我们就可以将更多的精力投入到关于更加长远的事宜计划上面,而且可以针对这些事宜需要作出的改变,提前和团队沟通,最终给予一套更加健壮、更符合系统模块本身的解决方案。

在整个软件开发周期中(比如一个敏捷开发周期),你对于整个周期中的事情了解的越透彻、越多,则越好。身为一个工程师,在一个开发冲刺中,为了在冲刺截止日期结束前,实现一个高效的、可靠的系统,你不会期望作出太多的改变,因此往往你可能会“偷工减料”。

从另一个角度来讲,我们也应当致力于在每一次面临需求变更的情况下,不需要一而再,再而三的更改我们已有的代码。所有新的功能都应当通过增加一个新的组合类或方法实现,或者通过复用已有的代码来实现。

插件与中间件

充分贯彻开闭原则的另一个例子,便是插件与中间件架构,我们可以从三个角度来简单分析这种架构是如何运作的:

  • 内核或者容器:往往是核心功能的实现的前提,一般会成为整个系统最核心的部分
  • 插件:在实现容器的基础上,往往一些核心功能都是以内置的插件实现的,并且,通过实现一套通用的网关类接口,我们可以使插件具有可插拔性,这样在需要新增特性和功能时,只需要实现新的插件并添加到容器即可,比如支持插件扩展功能的浏览器Chrome
  • 中间件:中间件我们可以通过一个例子来说明,比如我们拥有一个请求 - 响应周期,我们可以通过中间件,在周期中添加中间业务逻辑,以便为应用程序提供额外的服务或横切关注点,比如Reduxexpress还有很多框架都支持这样的功能。

总结

希望这篇文章能够帮助你学会如何应用开闭原则并且从中收益。设计一个具有可组合性的系统,同时提供具有良好定义的扩展接口,是一种非常有用的技术,这种技术最关键的地方在于,它使我们的系统能够在保持强健的同时,提供新功能、新特性,但是却不会影响它当前的状态。

译者注

开闭原则是面向对象编程中最重要的原则之一,有多重要呢?这么说吧,很多的设计原则和设计模式所希望达成的最终状态,往往符合开闭原则,因此需要原则也都作为实现开闭原则的一种手段,在原文的例子中,我们可以很明显的体会到,在实现开闭原则所提倡的理念的过程中,我们不经意地使用之前两篇文章中涉及的原则,比如:

  • 保持对象的单一性(单一职责)
  • 实现依赖于抽象(依赖倒置原则)

我之前一直是做后端相关工作的,所以对于开闭原则接触较早,这两年转行做了前端,随着nodejs的发展,框架技术日新月异,但是其中脱颖而出的优秀框架往往是充分贯彻了开闭原则,比如expresswebpack还有状态管理容器redux,它们均是开闭原则的最佳实践。

另外一方面,在这两年的工作也感受到,适当的使用函数式编程的思想,往往是贯彻开闭原则一个比较好的开始,因为函数式的编程中的核心概念之一便是compose(组合)。以函数式描述业务往往是原子级的指令,之后在需要描述更复杂的业务时,我们复用并组合之前已经存在的指令以达到目的,这恰恰符合开闭原则所提倡的可组合性。

最后在分享一些前端中,经常需要使用开闭原则的最佳业务场景,

  • UI组件的表单组件:对于表单本身以容器来实现,表单项以插件来实现,这样对于表单项如何渲染、如何加载、如何布局等功能,均会封闭与表单容器中,而对于表单项如何校验、如何取值、如何格式化等功能,则会开放与表单项容器中。
  • API服务:一般我们可能会在项目中提供自定义修改请求头部的工具方法,并在需要的时候调用。但这其实是一种比较笨的方法,如果可能的话,建议使用拦截器来完成这项任务,不仅会提供代码的可读性,同时还会使发接口的业务层代码保持封闭。
  • 事件驱动模型:对于一些复杂的事件驱动模型,比如拖拽,往往使用开闭原则会达到意想不到的效果。最近有一个比较火的拖拽库draggable,提供的拖拽体验相比其他同类型的库简直不是一个级别。我前段时间去读它的源码,发现它之所以强大,是因为在它内部,针对多种拖拽事件,封装了独立的事件发射器(其内部称作Sensor),之后根据这些发射器指定了一套独立的抽象事件驱动模型,在这个模型基础上,针对不同的业务场景提供不同的插件,比如:
    • 原生拖拽(Draggable)
    • 拖拽排序(Sortable)
    • 拖拽放置(Droppable)
    • 拖拽交换(Swappable)

还有若干提高用户体验的其他插件,这一切均是以开闭原则而实现的。

more