记一次对 CSS FlexBox 的误用

问题的介绍

最近开发某反馈系统的前端,偶然发现顶部导航栏的高度在 MacOS 或 iOS 下的 Safari 中计算异常:

首页的布局以及相关的样式大体如下:

1
2
3
4
<div class="rootContainer">
<nav class="navBar"></nav>
<div class="mainContainer"></div>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
13
.rootContainer {
display: flex;
flex-direction: column;

.navBar {
flex: 0;
padding: 8px 0;
}

.mainContainer {
flex: 1 1 auto;
}
}

这里 rootContainer 使用了 FlexBox 布局,使得顶部导航栏在固定高度的情况下,下面的主体容器能够自动将高度扩展直到占满视口的剩余高度。

然而,在 Safari 中,顶部的导航栏的实际高度为 16px(上下 padding 之和),而不是 Chrome / Firefox 中正常的 40px(padding 的 16px 以及其中的内容高度 24px)。

问题的解决

仔细查找资料发现,问题出在导航栏样式中的 flex: 0flex 属性在仅给定单个值时,会将 flex-grow 属性设置为给定的值,并将 flex-shrinkflex-basis 分别设置为默认值 10,因此 flex: 0 等价于 flex: 0 1 0%。然而,正如我们在上文看到的,这种赋值方式在 Safari 中会出现让人意想不到的情况。

其实从根本上来说, flex: 0 的使用是自相矛盾的,因为 flex-grow: 0 表明此元素不参与伸长,而flex-shrink: 1却又表明此元素参与缩小,但 flex-basis: 0 又表明此元素在不参与伸长时的高度(或宽度)应该为 0。这样我们也就不难理解为什么 flex: 0 在 Chrome 和 Safari 中存在如此之大的差别了。

综上所述,问题的根源在于 flex: 0 的使用不合理,应该修改为 flex: none

下面列举一些 flexbox 常用的赋值缩写:

1
2
3
4
5
6
/* 等价于 flex: 0 0 auto */
flex: none;
/* 等价于 flex: 1 1 0% */
flex: 1;
/* 等价于 flex: 2 2 0% */
flex: 2 2;

探究 DDD 在前端开发中的应用(二):什么是 DDD

DDD 简介

DDD 是英文 Domain-Driven Design 的缩写(不是 DDL Driven Development),中文译名为领域驱动设计,诞生于本世纪初。它为人们提供了战略和战术上的建模工具,帮助人们准确把握业务需求,产生精准的建模设计,进而高效率地设计、维护软件。

对于在软件开发领域中缺乏经验的许多大学生来说,DDD 更是提供了让他们看待代码的另一双眼睛,这双眼睛不仅能看到他们写出的每一行代码,还能看到在这些代码之上形成的结构,启发他们思考代码与代码之间的关系,进而让他们对架构形成一定的认识。如果说《C Primer Plus》、《C++ Primer》这些书籍能教你成为一名士兵,那么 DDD 这样的建模工具则能教你成为一名战略指挥官。

受篇幅限制,我们接下来只能对 DDD 的基础概念进行简要的介绍,如果你有时间,请阅读关于 DDD 的经典书籍《实现领域驱动设计》。

通用语言与限界上下文

DDD 作为一个建模工具箱,当然是为了建模而生的。简单来说,DDD 是使用通用语言(Ubiquitous Language)区分出的限界上下文(Bounded Context)来对需求的子域(Sub-domain)进行建模的工具箱。

通用语言是一些单词或者短语的特殊含义,或者开发团队之间形成的各种约定。通用语言是既描述了业务需求,又在代码设计中得到体现。限界上下文则是一个语义和语境上的边界,其中的每个代表软件模型的组件都有着特定的含义,并且处理特定的事务。这里界定所谓“特定”的概念就是通用语言。

在大型项目中,一个限界上下文往往对应一个由程序员、领域专家和产品经理等角色构成的开发团队。团队成员之间会在工作交流中使用这种通用语言,并且程序员书写出的软件模型的源代码就是通用语言的书面表达方式。因此,限界上下文和通用语言既是软件结构层面的设计,还是团队结构的设计,这一点和康威定律(Convey’s Law)不谋而合。


从漫画中可以看到,负责两个限界上下文的人员的办公室里传出来的语言是明显不同的

要进一步理解通用语言和限界上下文,我们不妨从它们的反面出发:在整个软件中随意使用语言,不划分任何模块。这样做会有什么后果呢?随意使用语言会提升开发团队的沟通成本,还可能导致误解的出现,甚至会导致产出的代码中产生严重的 Bug,或者偏离产品经理的预期。

例如在某个计分系统中,计分板代码中使用了 grade 这个词语来表示一个具体的分数,使用了 score()来表示教师的计分动作。然而,在计分板数据库代码中,使用了 score 来表示具体的分数。这样的结果就是,接手这些代码的程序员需要提高警惕以免混淆两边的 score,造成了注意力的浪费,并且在大规模的代码库中,长年累月的修改会使得两个 score 杂糅在代码中,留下了更大的隐患。

不严格划分模块,意味着负责不同功能的代码会在多次修改之后变得亲密无间,难分你我。这种行为或许在软件开发的初期有着较高的效率,但当软件开发进入维护阶段时,则会给维护软件的程序员带来噩梦,因为不同行为的代码混合在同一个文件夹甚至是同一个文件,导致程序员在下手修改某个行为前都需要仔细辨析相关的代码,甚至在修改后都在提心吊胆,生怕修改的代码会影响到其他功能的正常运行。


对付这样的代码就如同抽积木!

DDD 建模过程

从上文可以总结出,通用语言和限界上下文是相辅相成的关系。在开发的早期,面对给定的需求,开发团队会在头脑风暴时识别出一些存在差异的通用语言,然后将这些语言对应的组件划分给一个限界上下文。在开发过程中,甚至到了软件的维护阶段,限界上下文的发展又会反哺通用语言的进一步精细化、严谨化。

让我们先了解一下 DDD 建模的第一个步骤:开发团队首先进行讨论,发掘业务需求的全貌。然后开发团队会根据通用语言的不同划分出不同的子域,在不同子域中继续发展通用语言,让它们变得更精确严谨,使这个子域成为限界上下文。

开发团队中必须含有领域专家(Domain Expert)和软件开发人员。与软件开发人员关注于技术不同,领域专家更加关注实际的业务问题,他们会更多的从宏观角度分析业务需求,调动开发团队进行融洽的合作。他们非常了解如何进行领域设计来让公司的业务更上一层楼。

领域专家的存在使得软件开发人员不会在发展通用语言的过程中使其偏离实际的业务,导致整个项目充满了程序员间才懂得的语言,产品经理和程序员间出现沟通障碍等问题。因为领域专家有着和软件开发人员不一样的心智模型。DDD 中尤其强调拥抱业务需求本身,而尽量克制程序员“以技术为中心”的冲动。因此,软件开发人员需要和领域专家亲密合作,共同发展通用语言。


开发人员和领域专家所思考的事务往往是不同层面的

DDD 建模过程实例

让我们用一个实际一些的例子来说明这个过程,这个例子将在之后介绍 DDD 继续使用:假如有一个叫做 Vatrix 的课程平台被某公司正式立项,这个课程平台以课程为基础,提供程序设计作业的布置和在线评测功能。现在,假设我们是开发团队的一份子,让我们走进 Vatrix 开发团队的会议室中,参与 Vatrix 的头脑风暴!Vatrix 开发团队的需求分析会议正式开始,有请领域专家、程序员、产品经理等人进场。小明端庄地掏出粉色笔记本,向大家展示了它给出的领域设计草稿。团队成员们开始围绕业务需求进行探讨。

有人提出:课程平台应该来一个讨论区,让老师和同学之间可以对题目进行讨论。队员们普遍表示认同,于是平台的领域结构图被扩展了。其中的命名是领域专家和软件开发人员们共同商定的。

不一会,又有人跳起来说:咱们是不是忘记了一件重要的东西?我们应该提供一个题库功能供老师们创建和套用到课程中!扩展之后的设计图大家都很满意,于是 DDD 建模进入下一个阶段。

此时领域专家上台,将设计图按照通用语言的差异划分出了以下的子域。然后,开发团队将分为四组,各负责一个子域。工作开始后,小组内部会经过多次开发讨论,持续地为了实现业务需求发展子域的通用语言,限界上下文逐渐形成。发展通用语言的方式包括但不局限于:What-Why-How、Use Case、User Story 等,由于篇幅有限这里不作展开。

上下文映射

当限界上下文被发展出来之后,DDD 项目的核心域需要和其他限界上下文进行集成,这就是 DDD 的上下文映射(Context Mapping)。如果没有集成,那么我们的团队最后开发出来的成品只是四个孤立的组件而已,只有经过集成才能将它们连接到一起,成为一个能够正常工作的软件。例如,Course 限界上下文需要集成 Problem Database 限界上下文来实现业务需求中的出题功能。

上下文映射不仅表示了两个限界上下文的集成关系,还表示了两个团队之间的动态关系。由于不同的限界上下文的通用语言差异较大,因此团队之间处理上下文映射时尤其应当注意克服通用语言差异的鸿沟带来的潜在问题。两个团队之间存在的动态关系可能表现为以下的其中一种:合作关系(Partnership)、共享内核(Shared Kernel)、跟随者(Conformist)、开放主机服务(Open Host Service)。由于篇幅原因只列举了最常见的几种。

上下文映射的关系

合作关系是指两个限界上下文的团队之间具有一些重合度较高的目标以及实现思路。这样的团队会对开发进程的同步有较高的要求,因此经常会需要开会沟通,协同进度。

共享内核是指两个团队之间通过共享一个小规模但通用的模型来实现限界上下文的集成。例如负责资源监控限界上下文的团队要求负责邮件发送限界上下文的团队共享一个发布邮件的模型,来让前者能够在资源监控出现问题时发布邮件通知用户。

跟随者模式是指,当一个团队需要集成一个已经存在且规模庞大的限界上下文时(通常发生在下游团队和上游团队之间),下游团队无力翻译上游上下文的通用语言,而只能直接套用上游上下文的组件,因此下游团队成为了上游团队的追随者。例如我们在开发微信小程序时必须顺应微信团队的各种约定和要求。

开放主机服务是指团队定义一套协议或者接口,让自己的限界上下文可以作为一个服务让其他团队集成。这样的团队往往会提供一套详细的文档方便其他团队的开发。例如 Twitter 提供的公开 RESTful API 可以让每个开发者向自己的软件中集成 Twitter 的某些功能。

聚合

现在,让我们进入限界上下文内部,看看 DDD 为限界上下文内部的设计提供了什么样的工具。首先值得介绍的就是聚合(Aggregate)的概念。它指的是限界上下文中一个个的代码集合体,由实体或值对象构成,其中有一个处于根节点的实体被称为聚合根(Aggregate Root)。聚合根控制着所有聚集在其中的其他元素,并且它的名称就是整个聚合的名称。一个限界上下文可以存在多个聚合,下面只列出了一个。

从聚合开始,DDD 将触角伸向微观层面,指导我们如何结合系统分析,对每一个类进行设计。聚合中有一个非常重要的概念:每个聚合是事务一致性的边界。 所谓事务一致性,指的是在一个聚合中,所有被提交到数据库的事务执行后,整个聚合的所有组成部分必须严格符合业务规则。事务一致性的确定需要开发团队紧密结合业务需求来确定聚合何时处于有效状态,以防止破坏业务的操作发生。

看上去,这和数据库概念中的完整性约束、谓词约束等有相似之处,后者对数据表中的实体取值按照维护者的意愿进行限制。事实上,数据库的这些约束手段可以作为实现聚合的事务一致性的手段之一。不过,需要注意聚合的事务一致性更加强调:只能在一次事务中修改一个聚合实例并提交。

聚合的事务一致性约束开发团队在设计聚合时必须紧密结合业务需求,按照让聚合维持一致性和提交成功的方式来进行。这呼应了开头的那句话:“设计并不仅仅是感观,设计也是产品的工作方式。”此外应该注意,聚合的事务一致性必须由业务规则驱动,而不应该由技术驱动,否则我们将背离 DDD 的初衷。
这里列举一些设计聚合时值得参考的几条原则:

  1. 在聚合边界内保护业务规则的不变性。
  2. 聚合应该设计得小巧。
  3. 只能通过标识符引用其他聚合,而不是对象引用之类的。
  4. 使用最终一致性更新其他聚合。

领域事件

四条原则中的最后一条“使用最终一致性更新其他聚合”应该如何实现呢?例如,假设一位学生用户在 Vatrix 中加入了某个课程,那么 Account 限界上下文中的 Account 聚合应该提交对「已加入课程」的更新,但这只维护了这个聚合内部的事务一致性,我们还需要维护课程限界上下文中的成员列表聚合,向其中加入这位学生的学号才算是完成了业务需求的「学生可以加入课程」这一项功能。

这种对聚合之间最终一致性的需求催生了领域事件(Domain Event)的概念。它是一个聚合发布的、由利益相关的限界上下文订阅的事件,通常经由消息机制以发布/订阅的方式实现。下面我们结合上面的例子看一个图例。

如上图所示,CourseTook 聚合通过发布 StudentTookCourseEvent 这样一个领域事件到消息机制中,然后对此感兴趣的 MemberList 聚合将接受这个事件,并作出反应。它会从事件中读取到对应的学生学号,并将它加入到课程的成员列表中,最终一致性由此实现。

需要强调的是,领域事件在维护聚合一致性中如此重要,以至于它的任何接受者都必须贯彻落实相应的动作,不得因为任何原因放弃执行。同时领域事件的发布和传递必须高度可靠。

保障领域事件正确运行

无论是使用 MQ 还是其他方式实现承载领域事件的消息机制,必须强调的议题是消息机制的可靠性。当服务器机房的天花板漏水,或者是空调出现故障导致服务器,又或者是中大的施工队再次挖断电缆,导致服务器不得不中断运作时,我们当然不希望那些仍在传播中的领域事件消失,破坏系统的最终一致性。

因此,应该对各种领域事件进行有效的持久化处理,这同时也能作为一种聚合实例发生变更的历史记录,从而我们不仅能在意外出现时重建聚合,还能拥有一定的溯源能力,提高系统的可靠程度。

聚合的实现例子

下面让我们通过一段代码来实现上面所说的 CourseTook 聚合,假设它的构成如下图所示,其中 GPA 和 Description 是值对象,Assignments 是实体。我们的第一步是实现聚合的根节点 CourseTook 对应的类。然后再实现聚合根下的各种实体。

具体的实现请看本人写的一段 TypeScript 代码。请注意这样的一个类是如何拥抱业务需求,封装业务逻辑的。其中,聚合的实体类一般都会继承一个公共的基类 Entity,这个基类一般会负责维护唯一的实体 ID、比较逻辑,以及为子类提供发布领域事件的 API 等。

像这样设计聚合之后,我们的路由处理的逻辑将被大大简化,请看下面的 NestJS 的一段代码。

与那些直接将业务逻辑写在路由处理方法中的传统做法不同,我们将业务逻辑全部地保留到了实体本身中,而路由处理方法仅负责组装响应。这种做法与 DDD 中以业务需求为本的思想相统一,实体类中既封装数据,也含有业务需求中对数据做出的约束。这也使得业务逻辑与访问方式脱钩,也就是说,不仅是像上面的 RESTful API 可以复用实体创建逻辑,像 GraphQL 的 Resolver 也可以直接复用。

DDD 的整体架构

聚合的实现例子反映出了 DDD 不同的代码架构,请看下面的对比图。

在 DDD 中,Domain 层位于最核心的位置,并且有着一个严格的依赖规则:外层可以依赖内层,但内层不能依赖外层。这就是为什么 Domain 层需要通过领域事件去发布事件的原因。


来自https://www.jdon.com/ddd.html

小结

对 DDD 的基础概念介绍总算是告一段落了。遗憾的是由于篇幅原因,DDD 中还有许多经典的概念并没有纳入讨论范围。如果你有兴趣,推荐你阅读 Vaughn Vernon 写的《领域驱动设计精粹》和《实现领域驱动设计》。

本节内容的参考:

链接

探究 DDD 在前端开发中的应用(三):探究 DDD 在前端开发中的应用

前端之于 DDD

无论是 SSR 还是 SPA,结合上文讨论的 DDD 基础概念我们可以发现,从整个 DDD 软件的结构上看,前端主要处于 Presentation 层。也就是说,前端的主要任务是显示业务数据,并且支持用户交互动作,绝大多数的业务逻辑其实存在于服务端的 Domain 层中。当然前端有时候会存在一些业务逻辑,但它们大多都是服务于或者同构于服务端的,例如某些验证逻辑。不过总的来说,DDD 的重点,Domain 层位于服务端,而不是前端。

不过,这并不意味着 DDD 在前端就没有任何应用的空间。在 SPA 项目的职责不断变多、规模不断壮大,复杂程度不断提高的今天,DDD 奉行的一些理念以及倡导的一些范式依然能够在前端中派上用场。事实上,已经有一些先驱者为我们探索了 DDD 在 SPA 项目中的应用。

他们认为在 SPA 中,我们依然有理由去构筑一个 Domain 层,其中含有与服务端通信的数据模型,以及某些前端必要的业务逻辑,还有一些数据的验证逻辑等。我们可以在 SPA 中实现一个“残血版”的 DDD。下面,我们将探讨 Manfred Steyer 的利用 Angular 和 Nx Workspace 实现的 DDD,他为此特地编著了《Enterprise Angular》,电子版可以在网上免费下载。

Angular & Nx 简介

本节讨论的 Angular 是指由 Google 推出的 MVVM 框架 Angular 2,它使用 TypeScript 编写。虽然在全球范围内的人气不及 React 和 Vue 等竞品,甚至在国内都近乎无人问津(不过,中大教务系统却是使用它开发的)。但 Angular 依然凭借它的特色获得了大批程序员的好评。

它主要有两大特色:一是引入了一套依赖注入(Dependency Injection)系统,让代码块的复用变得更为简单;二是将组件分为模板(Template)、组件类(Component)、样式三个部分,分别对应.html、.ts、.css(或.scss 等)文件。

Nx 是由 Angular 开发组和前 Google 职员组成的组织 Nrwl 共同开发的,旨在为前端程序员提供一个打造 Monorepo 的工具箱。

Nx Workspace

Nx Workspace 为 Angular 开发者提供了一个开箱即用的 Monorepo 构建工具,它使用 Library 的概念来分割代码组件,强调不同的 app 可以复用组件以提升开发效率,减少重复代码。例如,对于一个预定机票的网页 App 项目,要求同时提供客户端和管理端,那么它的软件架构可能是这样的:


客户端 App 和管理端 App 分别利用了一个共享的 Library 和一个独享的 Library

Nx Workspace + DDD

Manfred Steyer 提出,我们可以使用 Nx Workspace 中的 Library 来组成 DDD 中的限界上下文。常用的几种 Library 的类型:

  • domain:含有业务数据模型,以及一些业务逻辑,数据验证等。
  • feature:负责用例的实现的 Smart Component。
  • ui:提供与实际用例无关的、可复用的 Dumb Component。
  • util:提供以下状态无关的服务、管道或者一些纯函数。
  • shell:这个限界上下文的入口 Library,负责路由等。

Manfred 同时强调,这些 Library 的逻辑层级存在区别,因而要严格控制它们之间的依赖关系。请看下面的例子,图中的箭头表示依赖关系。从 UI 相关代码的逻辑分层看,位于最高层的是 feature library,而像 util library 这样的则处于低层级。feature library 中的 Smart Component 是用户直接看到的组件,而 data-access 和 util library 中的代码则是被 feature library 依赖,但前者不能反过来依赖后者。这种依赖规则可以通过 Nx Workspace 提供的 library 依赖 lint 规则来实现,具体因篇幅有限不做阐释。


图片来自 Manfred 的《Enterprise Angular》

Domain Library

在数据层面上,feature library 会依赖 domain library 中供给的数据模型,以及验证逻辑等,而 domain library 则是整个前端软件中直接与服务端相连接的层级。这样的分层关系符合 DDD 中的要求,并且也契合了 DDD 中的以业务需求为中心的理念。下面,我们来仔细研究一下 domain library。

Domain library 中包含三个层面的代码,分别是 Application、Domain 和 Infrastructure。其中的 Domain 就是 DDD 中的 Domain 层,内含 Entity,存有数据模型,以及前端需要的部分业务逻辑。


图片来自 Manfred 的《Enterprise Angular》

下图是 Manfred 给出的 Domain 层的聚合例子,可以看到它和前面介绍 DDD 时给出的聚合例子几乎相同。这里再强调一下,DDD 抵触那些只有数据模型的实体类,并将它们成为「贫血领域模型」。有了 Domain 层的这些实体类之后,不要忘记我们正在编写前端代码,所以接下来我们要思考的是如何让我们的组件与服务端沟通,接收并显示数据。让我们来看看 Application 层中的 Facade。


代码来自 Manfred 的《Enterprise Angular》

严格来说,Facade 并不属于 DDD 的内容,但却是一个在 Angular 圈子里比较流行的实践,相当契合 DDD,有着以下优势:

  • 封装复杂的数据逻辑
  • 负责状态管理
  • 简化 API

而 BehaviorSubject 是 RxJS 中的概念,它可以存储数据,并通过订阅者模式向订阅了这个 Facade 数据的组件推送数据。


代码来自 Manfred 的《Enterprise Angular》

有了 Facade 和实体类之后,我们其实同时也实现了 DDD 中的领域事件:我们借助了 RxJS 的 Observable 实现了一个前端的消息机制。

一般来说,同一个限界上下文的 Domain 层仅为当前限界上下文的 feature library 服务。如果需要跨限界上下文的领域事件,可以另外使用一些消息机制实现,例如一些简单的事件发布/订阅库。至于 Infrastructure 层则比较简单,只是封装了对服务端提供的接口的 XHR 调用。

Nx Workspace + DDD 实例

接下来我们来看看一个实际项目的例子,取自 Matrix 即将上线的新版反馈系统。代码写于本人学习 DDD 的早期,对 DDD 的概念还不算熟练的时期,所以其中存在在一些瑕疵,我会在讲解时指出并给出符合 DDD 的修改方法,如果你有不同的意见,欢迎指出并讨论。我将在讲述 Library 时使用通知页面对应的限界上下文作为例子。


反馈系统的通知页面

为了使用 Nx Workspace 以及 DDD 来实现这样的一个反馈系统,我设计了如左图所示的几个限界上下文。其中通知页面的限界上下文中含有四个 library,形成的依赖关系图如下图所示。这里 feedback、notification 等限界上下文和服务端的限界上下文的划分是一致的。只不过服务端的 domain 层之外还存在有持久化层等。


右边是这些 Library 形成的逻辑层级,箭头表示依赖关系,可以看到在 notification 限界上下文中我们得到了一个清晰的 Library 的依赖关系,这将有利于我们区分代码职责,以及后续的维护。

下图是 domain library 中的 notification.entity.ts 和 update-notification-status.facade.ts。


我在 Notification 实体类中还引入了 class-transformer 包的修饰器,它可以自动将服务端传来的 string 类型的 time 转换为 Date 类。

下图是通知列表组件类,可以看到它是如何通过 Angular 的依赖注入来与 Facade 进行沟通的。
值得一提的是,markNotificationAsRead 方法中的行为我认为并不恰当。UpdateNotificationStatusFacade 应该在更新成功之后自己通过领域事件来通知 GetUnreadNotificationCountFacade 重新获取数据,而不是让组件类手动进行,导致这部分的业务逻辑泄露到了 feature 层中。

小结

关于 DDD 在前端开发中的应用,我依然在探索当中,上述内容很可能只是冰山一角。 Manfred 为我们提供了一条可行的道路,而在 Matrix 新反馈系统的实践中我依然遇到了一些比较棘手的问题,例如我起初一直无法在采用了 DDD 的 Nx workspace 项目中正确地配置 Angular 的路由模块以及懒加载,又如 Angular 的依赖注入系统在适配到懒加载的 library 时存在陷阱等。不过经过十几天的搏斗,Matrix 新反馈系统最终还是完成了开发,在这个过程中我不仅增长了对 DDD 的认识,也学习了很多 Angular 的冷门知识点,可以说是大有所益了。

Nx Workspace 除了支持 Angular 之外,其实也支持 React,并且 Github 上已经有一些 Star 很多的项目在应用着了。如果你有兴趣,可以在学习 DDD 之余看看这些项目。总的来说,DDD 是软件工程里的一座丰碑,启发了无数对架构感兴趣的程序员。

链接

探究 DDD 在前端开发中的应用(一):从软件开发的本质讲起

软件开发的本质

大多数普通人在听到“软件开发”这个词语的时候,会联想到这样一幅画面:一群穿着格子衫、桌子上摆着二次元手办的又瘦又高的眼镜仔坐在电脑前,对着屏幕上各种千奇百怪的代码敲打键盘,从白天直到黑夜。

这些给程序员加上的定语我们暂且不提,但软件开发并不是仅仅意味着编写代码。无论是在公司里上班,还是大学里几个伙伴的合作,软件开发的本质都是:编写代码来满足某种需求。公司雇佣程序员,是为了让程序员创造能够提升公司业绩的软件,进而满足公司盈利的需求;大学的伙伴合作,是为了创造一些好玩的软件,满足伙伴之间求知探索的需求。

由此我们可以看到,代码的编写是一种过程,服务于作为最终目标的需求。在软件开发中,需求占据了主导地位。

软件开发的难点

在软件开发中,人们往往会受到时间、成本、资源上的各种限制,或者是受到市场变化的影响,从而修改既定的需求。在软件开发的维护阶段,人们又经常会发现既有的代码设计难以让他们编写新的代码来满足新的需求。所有的这些问题都会导致生产效率的降低,进而导致公司的盈利空间的萎缩,程序员失业风险增加!

让我们看看一个例子:假如你是张三,老板给你下达了一个任务,让你编写代码,完成一个微信的投票小程序。当你信誓旦旦地接下了任务,埋头苦干了几天之后,老板突然告诉你:我们不做小程序了,能不能直接做成一个投票网页,DDL 不变行不行?你很可能无法说“不”,只能拼命想办法把现有的代码迁移到网页上。你会希望自己编写的一部分代码能够被直接复用,这样就不需要再重新编写。

因此,软件开发的难点在于:如何更好地适应不断变化的需求。

软件开发的救赎之路

自计算机诞生以来,前前后后几代的程序员都在不懈的探索这个难点的解决之道。在这个旷日持久的斗争中,人们将自己的实践经验总结下来,形成了一个个经典的编程范式。从某种意义上来说,这些编程范式就是一个个建模工具,对复杂多变的问题进行建模,从而得出一些普遍性的、不变的规律,进而简化人们的工作。

例如,我们在初中时学到:牛顿为了解释物体的运动,建立了经典力学模型,大大推动了人类天文学等学科的发展。类似地,在上世纪 80 年代,Smalltalk 的程序员为了解决桌面程序的代码过于繁复杂糅的问题,创造性地提出了 MVC 模式,为适应桌面程序中复杂多样的需求提供了一个优秀的工具箱。

MVC 模式全称 Model-View-Controller,将桌面程序划分为模型、视图和控制器三个层面,分别进行编码,使得程序员可以在同一个时间点仅关注一个层面的代码,符合关注点分离原则(SoF),同时写出的代码很容易符合单一职责原则(SRP),大大提升了程序员的编码以及维护的效率。

对那些还没有踏入企业工作的大学生来说,这些经典的编程范式也并非毫无价值,它们可以指导你作为一名程序员,将 if-else 摆放在合适的地方、将复杂的逻辑进行合理的分割等,别忘了,这些范式都凝结着老一辈的的程序员的宝贵经验。

我们今天要讲的 DDD 也是一种凝聚了几代程序员的心血的经典编程范式。它诞生于本世纪初,立足于改善当时 J2EE 或 Spring+Hibernate 等事务性编程模型只关心数据,造成失血模型不能很好地结合业务需求的问题。

在本节的末尾,我想引用一句史蒂夫·乔布斯的话:”设计并不仅仅是感观,设计也是产品的工作方式“。这句话不但阐释了他个人对设计的理解,也十分契合我们所讲的内容以及接下来将介绍的 DDD。在后文中,我会再次提到这句话,并向大家诠释 DDD 作为一种设计是如何塑造软件的工作方式的。

链接

探究DDD在前端开发中的应用:前言

此文章文字稿取自我在学校某实验室的公众号上发表的技术分享文章。

一直以来,软件建模都是一个非常热门的概念,它提供了一张将要开发的软件的蓝图,在系统需求和系统实现之间架起了一坐桥梁,而程序员实际编写出的代码能否很好地满足需求,很大程度上取决于选用的建模方法是否合理。

我们今天要介绍的 DDD(“领域驱动设计“的英文缩写)是软件工程中的一个著名的开发实践,给人们提供了战略和战术上的软件建模工具,能够有效地提高人们把握业务需求、生产精准的建模设计的能力,从而能够让软件的开发和维护更有效率。

本次技术分享将从软件开发的本质讲起,为大家简要介绍 DDD 产生的背景。然后我们将学习 DDD 的基础概念,对 DDD 形成一个大概的认识。最后我们用一个 Angular 结合 Nx workspace 的前端项目来讲解笔者对 DDD 在前端开发中的应用的探索。

由于笔者研究 DDD 的时间并不很长,若出现错误欢迎指正,有疑惑之处也欢迎讨论。

链接

从本博客的建立谈 Github Actions 与 Github Pages 搭配构建静态博客

前言

开设一个属于自己的博客站点对许多人来说是一件相当具有吸引力的事情。我们在博客中可以编写并发表一些日记或者文章,同时还可以让别人也看到并发表评论,与自己进行讨论交流。在笔者看来,开设这样的博客可以满足我们编写文章、寻求知音的需要,同时也可以在一定程度上逼迫自己努力学习知识、丰富个人生活,编写更加精彩的文章。

开设一个个人博客有非常多种的办法。自从笔者在 2013 年搭建起自己的第一个博客以来,笔者听说过或者接触过的开设博客的方法包括:购买虚拟主机并使用 WordPress 或者 Typecho 等博客软件、直接使用博客园或者简书等现成的博客平台、搭建静态博客等。本文将向读者介绍其中的第三种方法——我们会使用 Hexo 这个静态博客生成器,同时结合 Github Actions 和 Github Pages 搭建一个能够自动构建和部署的个人博客。本文还会简单涉及腾讯云 CDN 的配置,使用 CDN 来加速博客的访问速度。

静态博客是什么

所谓静态博客,在笔者看来就是将编写的一篇篇文章直接转化为一个个.html 文件,放置在 HTTP 服务器上直接供人访问的博客。与 WordPress、Typecho 这样的“动态”博客不同,静态博客不需要利用数据库存储我们编写的文章,也不需要使用 PHP 等服务端语言去处理人们的访问,静态博客只是简单地将我们编写的文章转换为.html 文件,然后像 Nginx 这样的 HTTP 服务器会将这些.html 文件直接传输到人们的浏览器上,从而让人们能够看到我们的文章。

静态博客相比 WordPress、Typecho 等软件而言有着很多优势,例如上手难度低、配置简单、需要的资源很少。例如,要搭建一个基于 WordPress 的博客,我们往往需要花费金钱去购买一个提供数据库和 PHP 环境的虚拟主机,而对于一个静态博客而言,可以直接将生成的.html 文件部署到成本低很多的 HTTP 服务器上。

本文要介绍的 Hexo(https://hexo.io/) 是一个较为热门的静态博客生成器,它能够将我们编写的.md 文件(即使用 Markdown 编写的文章)转换为.html 文件,同时提供了丰富多彩的插件和主题来进行高度定制化。笔者还会介绍如何使用 Github 来让 Hexo 在.md 文件被更新时自动编译出最新的.html 文件,然后部署到 Github 提供的免费的 Http 服务器上,同时笔者还会介绍如何使用腾讯云 CDN 加速国内用户访问这些 Http 服务器。

基于这样的方法构建的博客不会花费我们一分钱,因为我们不需要购买任何主机或者服务,而且在全国范围内无论是电信还是联通用户都有相当好的访问速度。同时,与其他许多静态博客的维护相比,编写文章并发布到博客的流程相当容易,只需要编写.md 文件,然后 git push 即可,Github 会跑完接下来的编译、部署流程。

Github Pages 和 Github Actions

在实际进行博客构建之前,我们有必要先来了解一下什么是 Github Pages 和 Github Actions。

Github Pages 为我们提供了免费的静态资源托管服务,而且支持 HTTPS。简单地说,它就是一台免费的 HTTP 服务器,能够将我们上传的.html、.css 等文件传输到访问站点的用户的浏览器上。我们将 HTML、CSS 等文件通过 git push 到 Github 上的仓库之后就能用 https://xxx.github.io/ 这样的 url 提供给全世界各地的人们访问。

Github Actions 则复杂得多,不过对于这篇文章而言,我们只需要这样理解:它为我们的代码仓库提供了一套功能强大的 CI/CD 服务,能够自动为我们运行 Hexo 博客生成器。更简单地说,它就像是 Github 提供给你的服务器,你可以在上面运行各种程序。我们可以通过它来运行 Hexo,进而将我们编写的文章(主要是 .md 文件)编译为 .html.css.js 这样的静态资源,还能自动将这些文件部署到 Github Pages 对应的仓库上。

看到这里,读者应该明白了构建博客的思路:搭配使用 Github Actions 和 Github Pages,前者负责将我们编写的 .md 文件转换为静态文件,后者则向全世界提供这些静态文件的浏览服务。接下来我们正式开始介绍构建博客的一系列步骤,如果你有疑惑或任何意见,请在评论区中友善发言。

第一步:建立必要的 Github 仓库

这里,我们假设你的 Github 的用户名(username)是 xiaoming。你需要在 Github 上建立以下仓库:

  1. 名为 xiaoming.github.io 的仓库。这将是你的 Github Pages 服务所对应的仓库。仓库中所有文件将会被 Github Pages 的 Web 服务器托管,供人们用 https://xiaoming.github.io/ 这个 URL 访问,正如上文所说。请注意 Github 限制个人的顶级 Github Pages 仓库(也就是这个https://xiaoming.github.io/ 对应的仓库)只能托管 master 分支上的文件。

    然后,请打开这个仓库的 Settings 页面,向下滚动到 GitHub Pages 一节。确保 Source 被选为了 master,同时将 Enforce HTTPS 打开,这样 Github pages 就会强制人们用 https://xiaoming.github.io/去访问了。

  2. 储存 Hexo 博客的仓库,这里假设名为 blog,你可以取其他名字。这个仓库将托管你的 Hexo 博客生成器相关的文件,以及你编写的 .md 文章。Hexo 博客生成器的目录结构是这样的:

1
2
3
4
5
6
7
8
node_modules/      <- Hexo的依赖项
scaffolds/ <- 自动生成的文章的模板
themes/ <- 主题相关的文件
source/ <- 你编写的.md文章
public/ <- Hexo构建博客最终生成的静态文件
_config.yml <- Hexo的配置文件
package.json <- Hexo的依赖描述文件
package-lock.json <- 不用理解

建立完仓库之后,我们来配置 Hexo 吧。

第二步:配置 Hexo

首先你需要在你的电脑上安装 Node.js,我们要用它来运行 Hexo。请访问 Node.js 的官网 https://nodejs.org/en/,推荐下载左边按钮的 LTS 版本,它往往更加稳定。

然后你需要安装 Git,我们需要用它来将本地的文件上传到 Github 的仓库中。请访问 Git 官网的下载页 https://git-scm.com/downloads

安装成功之后,在电脑上找个地方运行以下命令(注意根据自己的情况替换 xiaomingblog,其中 blog 是我们刚刚建立的仓库的名字):

1
git clone https://github.com/xiaoming/blog.git

如果是 Windows 用户,你需要使用鼠标右键菜单中的 Open Git bash here 来打开能够运行 Git 的命令行。

运行成功之后,你应该会收到 Git 的类似于下面这样的提示:

1
WARNING: You've cloned an empty repository.

它告诉我们克隆了一个空的仓库,这符合我们的预期,因为我们刚刚建立的仓库应该就是什么也没有的。

然后我们运行以下命令(请忽略 # 号后面的内容):

1
2
3
4
npm install hexo-cli -g #安装Hexo
cd blog/ #切换到刚刚克隆下来的blog文件夹
hexo init . #初始化Hexo,不要漏掉这个点
npm install #安装Hexo的依赖

通过这些命令,我们成功在克隆下来的本地仓库中安装了 Hexo,让我们先给自己鼓鼓掌。

此时你已经可以参考 Hexo 官网上的文档 来配置自己的博客了。同时你可以在任何时候通过运行这个指令:

1
hexo server

来建立一个本地的 Web 服务器来向我们的浏览器展示 Hexo 编译产生的博客,你可以通过它来预览你的修改结果。注意:你应该在 blog 目录下运行这个命令,否则 Hexo 会提示你博客不存在。

你此时也可以开始配置自己博客的主题,以及编写文章,具体请参考上面的文档链接里的内容。如果遇到疑难,可以使用搜索引擎搜索相关内容。

在万事俱备后,我们可以在 blog 文件夹里运行以下命令:

1
2
3
git add .
git commit -m "你的修改信息"
git push

来将你配置好的 Hexo 相关文件上传到之前在 Github 上建立的 blog 仓库。如果你在 git push 上遇到了困难,你可能是因为自己没有给 Git 配置 Github 的用户信息,具体请你使用搜索引擎查找。

第三步:配置 Github Actions

由于我们需要让 Github Actions 在运行 Hexo 之后将 blog 仓库中的 public 文件夹发布到 xiaoming.github.io 仓库中供人们访问,因此 Github Actions 需要 xiaoming.github.io 仓库的 ACTIONS_DEPLOY_KEY,否则它没有权限去做这样的事情。

接下来我们访问 https://github.com/xiaoming/xiaoming.github.io/settings/keys(记得将 xiaoming 替换为你的 Github 用户名)。请参考官方给出的 这篇文章的片段 来配置 ACTIONS_DEPLOY_KEY

配置成功之后,打开 Github 上的 blog 仓库,选择 Actions 标签页,如下图所示,点击 New workflow 按钮。

然后,点击 set up a workflow yourself -> 文字,如下图所示:

然后,在左边的代码框中的所有代码删去,写上我准备好的以下代码:

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
27
28
29
30
31
32
name: CI

on:
push:
branches: [master]
pull_request:
branches: [master]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2

- name: Setup Node.js environment
uses: actions/setup-node@v1.4.2
with:
node-version: 12

- name: Setup hexo
run: npm install

- name: Generate output
run: npx hexo generate

- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
external_repository: xiaoming/xiaoming.github.io
publish_branch: master
publish_dir: ./public

可以看到,这个文件描述了你的博客的构建过程,它选择最新版的 Ubuntu 作为基础,然后安装 Node.js,然后运行 npm install 来根据你的仓库中的 package.json 安装 Hexo 需要的依赖,之后就是运行 Hexo,产生输出,并将输出部署到 Github Pages 的仓库中。注意,请将最后几行中的 xiaoming 替换为你的 Github 用户名,其他不用改变。

然后,点击右上角的 Commit,向你的仓库中提交这个文件。之后,Github 就会在你通过 git push 向仓库推送修改(如修改配置、新增文章)时,自动为你运行 Hexo 进行博客的构建,并将产生的文件部署到 xiaoming.github.io 中,你就能通过 https://xiaoming.github.io/ 访问了!当然,别忘了在提交上面的文件之后,在你的本地仓库里运行 git pull 来将提交的文件更新到本地的仓库中,以免之后进行 git push 时发生冲突。

Github Actions 的一次成功的构建的例子,你其实可以在构建时实时查看控制台的输出

第四步(可选):配置个人域名和 CDN

如果你觉得 xiaoming.github.io 这个域名太过平凡,配不上自己的品味,那么我非常赞赏你的观点,因为我也是这种人(抬头看看浏览器地址框的 darkyzhou.net)。或许你也想来个 xiaoming.me 什么的域名指向上面创建的博客,可以吗?当然可以,Github Pages 早在几年前就全面支持了个人域名以及为个人域名提供 HTTPS 服务。

或许你会担心,如果购买域名会不会涉及到麻烦的备案流程。其实,如果你的域名是在国外的服务商购买的话,是完全不需要备案的,因为这个博客部署于 Github Pages 的服务器上,而直到目前为止,Github Pages 都没有在国内设置服务器,也就是说你的博客实际上是部署在国外的服务器上的。

然而,也正因为部署到国外服务器上,再加上国内特殊的网络环境,在很多情况下你的博客都会难以被国内的用户访问,有时甚至根本无法正常访问。此时你很可能需要一个国内的 CDN 去提高国内用户的访问速度。(关于 CDN 的知识这里略过,如果你不了解请使用搜索引擎搜索)。

然而,如果你选择在国外购买域名,你就很可能会在 CDN 上遇到困难,因为国内的大多数 CDN 都需要你为加速的域名备案,一旦域名需要备案,那么域名指向的博客网站也需要备案(我不太确定这两种备案是不是实际上是同一种备案)。如果你选择在国内的服务商购买域名,例如本博客的 darkyzhou.net 就是在腾讯云上购买的,那么你可以享受到国内的服务商给你提供的方便快捷的备案服务,以及免费但有限的国内 CDN 服务。因此,我建议你在国内的服务商购买域名,同时进行备案。其实,就腾讯云提供的备案服务来说,流程并不复杂,而且时间也相对较短(我从提交备案材料到备案通过只花了两周)。

当你购买好域名,备好案之后,我们进行以下步骤来让你的博客拥有自己的域名,以及国内 CDN 加速。假设你的域名是 xiaoming.me,使用腾讯云 CDN(其他 CDN 的步骤类似)。

首先,配置你的 CDN,在 腾讯云的 CDN 控制台的域名管理页 中,点击 添加域名 按钮,域名填写 xiaoming.me,其他配置如下图:

注意以下几点:

  1. 修改回源域名为你自己的域名。
  2. 源站地址的几个 IP 是 Github Pages 服务器的四个主要 IP,不直接使用 github.io 是因为可能会出现奇怪的问题。
  3. 缓存配置中的设置是我根据经验设置的,主要是为了确保首页和 Hexo 博客的文章页、归档页和标签页能够及时的刷新。你可以根据你使用的静态博客来设置响应的缓存配置。

然后,点击提交,待开通成功后,进入管理页的 Https配置,选择你的 HTTPS 证书。如果没有,可以去 腾讯云的 SSL 证书控制台xiaoming.me 申请一个免费证书。配置成功后,开启 HTTP 2.0,以及 强制跳转

与此同时,在域名控制台配置你的域名解析:

  1. xiaoming.me(主机记录 @)使用 CNAME 解析到你的 CDN 提供的加速地址,注意路线类型选择 境内
  2. xiaoming.me(主机记录 @)使用 CNAME 解析到 xiaoming.github.io,注意路线类型选择 境外
  3. www.xiaoming.me(主机记录 www)使用 CNAME 解析到你的 CDN 提供的加速地址,注意路线类型选择 境内
  4. www.xiaoming.me(主机记录 www)使用 CNAME 解析到 xiaoming.github.io,注意路线类型选择 境外

配置好后需要等待几小时来生效。

之后,打开 Github 向第三步中创建的 .github/workflow/main.yml 文件中末尾部分添加一行 cname: darkyzhou.net,如下面所示

1
2
3
4
5
6
7
8
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
deploy_key: ${{ secrets.ACTIONS_DEPLOY_KEY }}
external_repository: xiaoming/xiaoming.github.io
publish_branch: master
publish_dir: ./public
cname: xiaoming.me

等待 Github Actions 重新构建,成功后,你应该可以在 xiaoming.github.io 的仓库的 Settings 页面的 Github Pages 一栏中看到 Custom Domain 被设置为了 xiaoming.me,如果没有请手动设置并应用。然后,选择 Enforce HTTPS,如果这个选项是灰色的,并提示出错,请稍等几小时,或者使用搜索引擎。

一切成功后,你的 Github Pages 一栏将如下图一样:

此时,打开浏览器,向地址框中输入 xiaoming.me,你将被导航到 https://xiaoming.me 并快速地加载出你的博客!

如果你想测试一下你的博客在开通 CDN 之后的访问速度在全国范围内究竟有没有提升,可以使用像 http://tool.chinaz.com/speedtest.aspx 这样的测速工具。下图是本博客的测速结果:

速度还算令人满意,不过据说又拍云的免费 CDN 有着更好的效果,或许将来有空可以试试。作为对比,请看看 darkyzhou.github.io 的测速结果:

测速结果非常不稳定,这次是绿下次就变成了橙甚至红。在国内没有 CDN,真是寸步难行呀。

下一步做什么?

通过以上步骤,你现在已经有个一个操作方便、访问快速的静态博客,接下来你应该去进一步做一些让你的博客变得更好的工作了,例如:

  1. 配置 SEO。合理运用 Google Search Console,以及百度资源平台,以及 Hexo 的 Sitemap 生成插件。
  2. 引入 Google Analytics(它的数据统计服务其实可以在国内访问,只是控制台不能)。它可以统计你的博客的访客信息,方便你了解你的博客的被访问情况,以及访客的喜好等。
  3. 培养一份责任感,维护好自己的博客,坚持持续编写文章。其实在我眼里,个人博客的地位相当于我们在广袤无垠的网络世界中的一个我们实际占据的角落,有了它,我们也相当于在网络世界中占据了一定的地位,显得不再那么虚无缥缈。这个博客不仅可以充当树洞,也可以发一些自己的技术文章。不必担心没有人看,因为总会有人看的,或者你并不追求有人能看你的博客,你只是希望充实自己在网络空间中的角落,充实自己的知识罢了。

从 package.json 和 package-lock.json 浅谈 npm 解析依赖出错问题

引子

在 Node.js 的世界中,人们几乎每天都在使用着 npm install 命令。这个命令会根据 package.json 的内容,将项目所依赖的库安装到本地的 node_modules 文件中,然后产生一个新文件 package-lock.json

许多人都知道,后者的作用是保存 npm 按照 package.json 的内容经过计算而构建的依赖树,对应本地的 node_modules 的文件结构。并且,之后执行的 npm install 都将围绕这棵依赖树进行。从某种意义上来说,package.json 就像是构建本地依赖树的蓝图。

关于 package-lock.json 有一种广泛流传的观点是:在共享给他人的项目中,应该提供它来确保其他人通过 npm install 所获得的的 node_modules 和原作者的保持一致,从而确保不会出现依赖的版本出错而导致配置本地开发环境失败。

然而有些时候,我们仍然会在使用 npm install 之后出乎预料地遇到了依赖版本出错的问题;或是在团队开发中偶然发现自己执行 npm install 之后, package-lock.json 的内容竟然发生了改变,旋即犹豫不决:为什么发生这种问题呢?要不要恢复到 HEAD 中的版本呢?

别着急,本篇文章就来讲讲 package.jsonpackage-lock.json 的关系,为你解决上述问题提供思路!

语义化版本

在讲述它们的关系之前,我们有必要先来理解一下 Sematic Versioning (简称 SemVer,中文名为“语义化版本”) 规范。它被选为 npm 社区的首选版本规范,npm 上绝大多数的依赖库都采用了这种版本规范。

以语义化版本号 X.Y.Z 为例:

  • X 是 Major version,表示一次不向后兼容的变更,例如 API 的变更。
  • Y 是 Minor version,表示一次向后兼容的变更,通常是添加一些新功能。
  • Z 是 Patch version,表示一次向后兼容的 Bug 修复,或者是文档的修改。

我们再来看看 package.json 中对语义化版本的使用:

1
2
3
4
5
"dependencies": {
"@angular/common": "^9.1.0",
"rxjs": "~6.5.4",
"@nrwl/angular": "9.4.5",
}

上面的例子中:

  • ^ 在 npm 眼里表示“安装这个库最新的 Minor version 或 Patch version,但 Major version 保持不变。”。所以对于 ^9.1.0来说,这份 package.json 的用户如果运行 npm install,是完全可能在实际上安装了 9.2.0 版本的,这会体现在他的 package-lock.json 中。
  • ~ 在 npm 眼里表示“安装最新的 Patch version,但其他必须不变”。与上面类似,~6.5.4 说明只允许安装 6.5.46.5.6 这样的版本。
  • 如果版本号前既没有 ^ 也没有 ~,则说明 npm 必须安装完全一致的版本。

在开发者使用 npm install <dep_name> 来安装依赖时,npm 默认会在 package.json 中写入前面带 ^ 的版本号。因为 npm 认为,既然 SemVer 被选为了首选的版本规范,那么这些库的开发者们都应该严格地遵循它,不应该在 Minor version 和 Patch version 中引入不向后兼容的变化,默认使用 ^ 还能在最大程度上保证使用者安装的依赖的版本是最新的。基于以上原因,大多数的 npm 项目的 package.json 里的版本号都是 ^X.Y.Z 形式的。

然而,npm 这样的假设在实际中是存在隐患的。我们先按下不表。

package.json 与 package-lock.json

基于上述的假设,npm install 会同时参考 package.jsonpackage-lock.json,并确保两者表示的依赖的版本能够符合上述的规则。下面用一个例子来阐释 npm install 的行为:

假设你 clone 了 A 项目,A 项目的 package.json 的依赖部分如下:

1
2
3
4
5
"dependencies": {
"a": "^1.1.0",
"b": "~2.2.0",
"c": "3.3.0",
}

在 A 项目提供的 package-lock.json 中,三个依赖的版本解析结果分别为 1.2.42.2.43.3.0。那么你在运行 npm install 后将会得到一模一样的解析结果。这里 npm 遵循了 package-lock.json 的结果。

然而,如果 A 项目的作者通过 npm install xxx 安装新的依赖,而忘记 commit 更新后的 package-lock.json,那么这会导致提供给用户的 package-lock.json 存在偏差,比如 a 依赖的版本不是 1.2.4 而是 1.0.4。你在运行 npm install 后,package-lock.json 将会被修改,其中的 1.0.4 所在的节点会被修改为你的 npm 的解析结果,即最新的、可以满足 ^1.1.0 规则的 1.2.4。这里 npm 遵循了 package.json 的规定。

更新 package-lock.json 的隐患

如上文所说,npm install 可能会自动更新本地已经存在的 package-json.json。同样使用依赖 A 的例子,这里的隐患是:如果这个依赖 A 没有很好地遵守 SemVer,或者是因为代码规模太大而出现疏忽(这非常常见),导致 1.3.1 中出现了不兼容 1.2.0 的代码部分,会如何呢?对,你将无法正常运行 clone 下来的项目。因此如果你在运行 npm install 之后,package-lock.json 发生了改变,你应该警觉这一个可能性。

npm 为了缓解这个问题,实际上专门引入了一个特殊版本的 install,叫做 clean-install,简写 ci。它在进行 npm install 所做的工作时,会检查一些条件,如果解析 package.json 的结果与已存在的 package-lock.json 不符,那么它将报错退出,同时若本地已经存在 node_modules,它将事先删除。

npm ci 并没有彻底解决这个问题,它只是防止产生可能有误的 package-lock.json。如果我们的项目是发布到各大 registry 上被人们使用 npm install xxx 安装的,那么我们的用户的 npm 是不会遵守我们的 package-lock.json的(这是设定,用户的 npm 只会遵守它的项目本身的 package-lock.json), 那么我们又该如何去从避免用户的 npm 解析出不同的依赖树呢?

有人提出:不要使用 ^,甚至是 ~ 也不要使用,定死尽可能多的依赖的版本号,这样就能在最大程度上杜绝上述隐患。定死版本号虽然能够缓解隐患,但也带来了这样的问题:用户再也不能通过 npm install 来自动更新那些进行了 bug 修复的依赖,使得你必须及时更新使用了新依赖的新版本,但依然造成了不便。

解决思路

笔者综合一些参考资料,认为:我们不应该一律采用 ^ 规则,而应该根据实际情况,灵活地定义版本规则。我们来看看 nx workspace 给开发者自动生成的 package.json(略去了一些项目):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
"dependencies": {
"@angular/animations": "^9.1.0",
"@angular/common": "^9.1.0",
"@nrwl/angular": "9.4.5",
"rxjs": "~6.5.4",
"zone.js": "^0.10.2"
},
"devDependencies": {
"@angular/cli": "9.1.0",
"@nrwl/workspace": "9.4.5",
"@types/node": "~8.9.4",
"dotenv": "6.2.0",
"ts-node": "~7.0.0",
"tslint": "~6.0.0",
"typescript": "~3.8.3",
"@angular/compiler-cli": "^9.1.0",
"@angular-devkit/build-angular": "0.901.0",
"codelyzer": "~5.0.1",
"@types/jest": "25.1.4",
"ts-jest": "25.2.1"
}

nx workspace 会给用户生成这样的 package.json,然后调用用户的 npm 进行 npm install 产生 package-lock.json。这一过程和上面讨论的用户使用 npm install xxx 安装你的库几乎一致。

我们可以看到,上面的片段混用了 ^~ 规则和固定版本号。这样做有以下的好处:

  1. 能够兼容那些不遵从 SemVer 的依赖。例如 TypeScript,它特别喜欢把 SemVer 中的 Minor version 当做 Major version 来使用,经常会出现 3.x.y 不兼容 3.(x-1).z 的问题。所以 nx workspace 就限定了 ~3.8.3,因为 TypeScript 暂时还保留些许克制,不会在 Patch version 中引入不向后兼容的改变。
  2. 缩小用户进行 npm install 后产生错误的依赖树的排错范围。我们能够知道哪些依赖严格遵从 SemVer,所以能够套用 ^~ 规则;也能知道哪些项目规模庞大,大到哪怕是 Patch version 的更新都可能会无意中导致不兼容的改变,从而使用固定版本号······总之我们可以根据自己的需求,灵活地套用规则。这样我们就能够将可能发生错误的隐患限制到了一个非常小的范围,易于排查。

当然,对于规模尚小的项目而言,全部采用 ^ 规则几乎不会出现差错,但是当你的用户向你发出 Issue 时,你就是时候要检查一下自己的 package.jsonpackage-lock.json 了。

参考资料

浅谈 JavaScript 异步编程(四):JS 异步编程的另一种思路

什么是响应式编程

在 JavaScript 还在被 Callback Hell 的阴影笼罩时,来自 ReactiveX 组织的仁人志士,为 JavaScript 世界带来了被广泛使用的响应式编程。响应式编程和我们熟悉的命令式编程有很大的不同。它和声明式编程很想象,但强调使用可观察对象构建异步数据流。下面让我们通过一个例子来体会一下它们之间的区别:

拉取体系与推送体系

在继续介绍 RxJS 之前,我们有必要弄清楚所谓拉取体系(Pull System)和推送体系(Push System)间的区别。其中,后者是响应式编程的根基。

“拉取和推送是两种不同的协议,用来描述数据生产者 (Producer)如何与数据消费者 (Consumer)进行通信的”,RxJS 官方文档如此描述。在拉取中,消费者占主动地位,而生产者占被动地位,消费者自行决定什么时候接受数据,而生产者并不知道何时发出数据。在推送中,以上关系发生颠倒,由生产者决定何时发出数据,而消费者只能被动接受。

拉取体系与推送体系

在 JavaScript 中,拉取体系的典型例子是 Iterator,其中的消费者就是 iterator.next()。推送体系的典例是 Promise,其中的消费者是 promise.then()

利用 Generator 实现的求斐波那契数列的函数

从上图的代码中可以看到,只要 iterator.next() 不被调用,也就是消费者不去拉取数据,那么 fib() 内的代码就会被挂起。因此,在拉取体系中,我们主动向生产者索要数据。

我们回想一下先前的 fetch().then()fetch() 是生产者,then() 中的函数是消费者。此时并不存在我们拉取数据的说法,fetch() 自动启动,我们能做的只能被动接受它产生的数据,而且时机是不确定的。故在推送体系中,我们只能被动接受。

Observable 与异步数据流

由之前的叙述我们知道,JavaScript 本身存在着应用响应式编程的空间。然而,其中存在一道缺口:对于推送体系中的产生多个值的数据源,JavaScript 本身并没有很好的支持。先前提到的 Async Iteration 用途场景较为受限,很难完全填补这里的空白。至此,我们总算要开始介绍 RxJS 的核心概念——Observable 与异步数据流

RxJS 为了填补这道缺口,提出了 Observable 对象,代表能够产生若干个值的异步数据源。同时,采用观察者模式,让数据消费者能够通过订阅数据源来被动消费数据。

异步数据流则强调 Observable 产生的数据仿佛溪流一般,消费者既能改变流向,又能在溪流的任意位置获取数据。

下图是一个简单的例子,它以按钮被点击的事件作为 Observable,我们的消费者就是第二行中订阅函数的参数。注意这个 Observable 是一个典型的异步数据源:它可能产生若干个值,并且存在一定时间跨度。我们可以定义自己的 Observable,目前 RxJS 原生支持将 DOM Event、setInterval、Promise 等转换为异步数据源。

为了增强异步数据流给编写异步代码的舒适性,新版本的 RxJS 引入了 Piped Operators,使得我们可以顺着异步数据流的“溪流”,在数据流淌的过程中对数据做出修改,并让修改后的“溪流”最终流向我们的目标(消费者)。例如,使用 map 可以利用纯函数来修改输入的数据,并让修改后的数据继续沿着“溪流”流向下一个 Operator 或消费者。

实例

这是笔者参与维护的学院的课程平台。平台上的这个页面显示着从服务器获取的未结束的课程,并且支持使用搜索框筛选显示的课程。我们不妨想想,要如何使用 RxJS 实现这个页面的功能呢?

显然,卡片容器是数据消费者,它需要接受一个表示课程信息的数组。而从服务器获取的所有未结束的课程列表,以及搜索框的内容会是我们的两个数据源。因此我们的目标就是整合这两个数据源,并让搜索框能够发挥它的作用。下面来看看实际的代码以及概念图:

P1、P2、F1、F2 是几个不同的 Operator,用于对数据流做特殊的修改,这里暂不探究启具体作用。

可以发现,我们的这些代码几乎就像一篇操作指南一样,告诉了大家我们对数据流做了什么,这就是响应式编程的美妙之处;并且,异步数据流的概念也很符合我们对数据处理的思考,先干这个再干哪个,中间没有任何东西打断我们的思路。

小结

RxJS 的世界远远比先前的介绍更加复杂,但也更加有趣,这里由于篇幅原因只能介绍这些。总而言之,RxJS 代表的响应式编程是人们对 JavaScript 异步编程的一项伟大的补充,它解决了多数据异步数据流的处理问题,并且带来了许多理念先进且使用便捷的 Operators。

链接

浅谈 JavaScript 异步编程(三):JS 异步编程的发展

混乱的 Callback

正如先前所述,JS 运行环境在处理收到的任务之后,主要是通过 Callback 来通知 JS 这个“大老板”的。Callback 本质上是一个接受若干参数的函数。

在 Promise 还未出现的年代,人们只是在“要有 Callback”上达成了广泛共识,并没有在“Callback 应该需要什么参数”、“Callback 应该什么时候调用、怎么调用”这类细节问题上形成统一的标准。并且,从形式上看,Callback 是一个作为参数的函数,如果出现复杂的异步任务,人们很容易写出嵌套的 Callback,既不美观,又影响了程序员的思维。同时,Callback Hell 的产生又让人们伤透了脑筋。

对于一些复杂的异步任务,人们很容易写出像上图这样糟糕的代码。这种层层嵌套的 Callback 又被成为 Callback Hell。

Callback Hell 不仅难以阅读,更为严重的是它能够打乱程序员对于复杂的异步任务的思维流。Callback 天生就不符合人脑的“顺序思考”特性,而 Callback Hell 则有过之而无不及,大大增加了人在这段代码中犯下低级错误的可能性,为系统的稳定带来风险。

与此同时,几乎每一个广泛使用的库或者其他 API 对自己异步操作的 Callback 都有着不同的设计。这些各自不同的设计让当时的人们很是痛苦,每用一个 API 就要去查一查参数、异常处理等细节的文档。看看下面这几个常见的异步 API 到底使用了多少种不同的设计方案:

缺乏细节而又容易产生问题的几个例子:

  1. 异步任务会存在哪些状态?
  2. 什么时候调用错误处理 Callback?调用几次?什么参数?
  3. 在操作成功后是不是马上调用 Callback?
  4. 传入 Callback 的参数会不会因情况而异?

在这个例子中,我们的程序要在运货 API 检查到缺货并抛出错误时,给已经付款的用户退款 1000 美元;在检查到货品充足时给用户显示成功信息。

可是,我们没有想到的是,运货 API 检查缺货的过程中会同时进行很多小步骤,而每一个小步骤出错都可以导致这个 Callback 被调用,也就是说这个 Callback 可能会被用异常对象调用多次,你会给用户多退款好几千美元!

像这样的问题被称为信赖问题。在这种问题中,你很难保证 Callback 会 100%按照你设想的方式被调用。因为这些多样的 API 对于如何对待你的异步操作并不存在一种统一的规范。

正规军 Promise

Callback 标准的混乱问题源于当时 JS 的标准并没有在这一领域带来某种官方认可的规范,于是第三方各自为战。Callback 自身的缺陷使得它无法适应正在变得越来越复杂的异步任务,最终导致了 Callback Hell 的肆虐。

事情终于在 2015 年迎来转机。JS 的标准制定者们综合了全世界范围内的反馈后,终于在 ES6 标准中正式推出了官方的异步编程 API——Promise API。

Promise 是异步操作的抽象封装,它对这些异步操作进行了合理的限制,在很大程度上缓解了信任问题,因为所有的行为变得可预测、可控制起来。Promise 正如其名,是 JavaScript 官方给与我们的异步编程的“承诺”。

让我们来好好地认识一下这个 JS 异步编程的“正规军”有什么特点吧:

  • 一个 Promise 实例一旦被创建,异步操作就开始进行,不能中止。

  • Promise 在创建后的任意时间点上只能存在以下三种状态的其中一种:

    1. Pending(进行中)
    2. Fulfilled(操作成功)
    3. Rejected(操作失败)

    并且,这三种状态间的转换关系是单向的,不可逆的。

当 Promise 处于 Fulfilled 或 Rejected 状态时,我们说这个 Promise 已经决议(resolved),而且一旦决议则永不改变。

现在我们知道:Promise 代表的异步操作要么顺利完成,要么因为错误而失败,绝不存在模棱两可的状态。而且,状态的不可逆,又使得 Promise 摆脱了修复、重试动作带来的复杂性。若要在一个 Promise 失败后重试异步操作,只能重新构造一个 Promise 实例。

这样的设计很好地缓解了异步 API 的信赖问题,因为当这些 API 都采用 Promise API 之后,我们在处理这些 Promise 的方式上就能获得统一性,从而让我们得以将宝贵的注意力集中在我们真正需要关心的部分——具体的业务数据上。

Promise Chain

Promise API 除了 Promise 的理念外,还引入了功能强大的 Promise Chain。下图就是先前的 Callback Hell 例子用 Promise 改写后的版本(假定 fs 库支持 Promise)。这里,每一个 then 函数的参数都是一个接受上一个 Promise 的结果,产生下一个 Promise 的函数。也就是说,我们能通过 Promise Chain 将一系列复杂的异步操作用 Promise 表达,并将它们串联起来构成一个可读性极佳的整体,成功地摆脱了 Callback Hell!

Promise Chain 的例子

then 函数也接受第二个参数用于处理上一个 Promise 的错误,默认抛给下一个 then 函数。catch 函数是一个特殊的 then 函数,它只接受处理错误的函数。

受到广泛应用的 Promise API

自从 Promise API 正式推出以后,各大异步 API 以及浏览器的许多 API 都跟进了对 Promise 的支持。在 2020 年的今天,我们能接触到的绝大多数与异步操作相关的 API 都提供了对 Promise API 的支持(除了 Node.js 的官方库之外)。

浏览器中新推出的用以取代老旧的 XMLHttpRequest API 的 Fetch API

ES2020 标准中引入的 Dynamic Import

Promise API 的不足

Promise API 并非完美无瑕。虽然 Promise Chain 让复杂的异步操作代码的可读性大大提高,但其实并没有从本质上解决 Callback 模式的一大核心痛点:不符合人脑的“同步思维”。人脑习惯于以时间先后顺序连贯地思考一系列复杂的操作,但无论是 Callback 还是 Promise Chain,都在某种程度上打断了思维的连贯性。

在“先”与“后”之间始终存在着阻隔思维流的代码

后起之秀 async/await

我们看来还是习惯于平常编写的同步式的代码。要算一个平方并输出,我就先 let square,再 square = getXXX(),然后 console.log(square) 就好了,这很符合我的思维习惯。于是有人利用 ES6 标准中引入的 Generator,实现了用同步式的代码去表达 Promise Chain!

function 后的星号表示这是一个 Generator,而 yield 表示产出后面的 Promise。Generator 在执行到 yield 并产出值之后就会暂停执行,直到外部调用 next() 才能恢复执行,直到下一个 yield,由此往复直到末尾。

要运行这个 Generator,需要借助一个特殊的函数的帮助:它会自动处理通过 yield 产出的 Promise,并将 Promise 的结果用 next() 塞回去,然后 Generator 继续运行到下一个 yield xxx,直至末尾。

通过和特殊函数的“一唱一和”,我们顺利实现了用同步式的代码表达 Promise Chain。不过最终的代码看上去很别扭,而且还得额外写出这个特殊的函数,还是有些麻烦。

好消息是,这种创造性的用法马上就被列入到了 ES7 标准当中,那个特殊的函数已经被纳入了 JavaScript 的引擎之中,不必再额外写上。并且,为了改善可读性,ES7 还专门增加了两个关键字:asyncawait。让我们用 async/await 重构上面的例子(仅是示例,fs api 至今不支持 async/await):

如今 async/await 在 JavaScript 的各大运行环境中都有了广泛的支持。

展望未来

人们对于 JavaScript 异步编程的探索仍在继续。自 async/await 被列入标准后,人们陆续又提出了诸如 Async Iteration 这些让编写异步代码更加方便的概念。

一个 Async Iteration 的用例,需要实现 Async Iterator 或 Async Generator

小结

JavaScript 异步编程在经历了混沌的 Callback 时代后终于迎来了 Promise API 的曙光,现如今,我们几乎能在 JS 的一切使用场景中看到 Promise 的身影,如 then() 等字眼。

变得越来越强大的 JavaScript 也在这段时期促进了前端的工程化、前后端的语言统一以及伟大的前后端分离战略等,其中当然也少不了这些异步 API 的功劳。

接下来我们来研究一下 JS 异步编程的另一种思路——响应式编程。

链接

浅谈 JavaScript 异步编程(二):JS 异步编程的基石

引擎与运行环境的“角逐”

我们再来看看刚才的这段代码,Node.js 在读取文件结束后将 Callback 交给 JS 引擎执行,但这其中有一个问题:运行环境交还 Callback 的时机如何确定?

如果运行环境在任务结束时立刻将 Callback 交还引擎,而此时引擎很可能正在执行一个函数的中途,那么强行插入执行的 Callback 很可能会导致一些难以预料的后果。

例如这段代码,setTimeout 是运行环境提供的一个定时器,我们这里将定时器设为 0,也就是说运行环境得到这个任务后可以立即完成。假如运行环境完成后立即塞回引擎执行,那么counter is 0既可能被打印一次,也可能被打印两次。

JS 作为一门单线程语言竟然会出现多线程并发问题!

事件循环与任务队列

幸运的是,上述的情况是不可能在 JS 中发生的。为了让引擎和运行环境在恰当的时间点交互,运行环境都采用了一套基于事件循环与任务队列的设计。

上图为浏览器中的事件循环与任务队列模型,它最初由 HTML5 标准制定,后来 ES6 标准又作出了修改。

此模型除了引入事件循环和任务队列的概念,还规定了一个基本原则:每当引擎的 stack 被清空后才运行事件循环清空任务队列,由此往复。
先前的代码在 for 中执行时 stack 始终不为空,所以运行结果一定是:counter is 0只被打印了一次。

下面我们通过一个具体的例子来理解这个模型:

小结

事件循环与任务队列的引入为 JS 执行异步代码提供了一个安全的、稳定的、可预测的环境,成为了 JS 异步编程的基石。

另外,ES6 标准中提出了宏任务与微任务的概念,并对运行环境提出了进一步的要求,例如不完成所有微任务就不能进入下一次事件循环等,让 JS 在拓展异步编程的功能时有了更为安全可靠的保障。

接下来让我们继续前进,探索 JS 异步编程的发展轨迹。

链接