如何进阶为高级开发工程师?

senior developer

成为高级开发工程师是多数人在编程生涯中的奋斗目标之一,那么成为“高级”开发工程师意味着什么呢?

在开始之前,我们先消除下对于高级开发工程师固有的一些观念。与招聘网站上列出的绝大部分职位不同的是,高级开发工程师的定义其实并不仅仅和你简历上的工作年限严格相关。

确实,通常更多年限的工作时间往往伴随着更多工作经验,甚至有些公司仅仅根据工作年限来确定你的级别。但事实是,工作年限并不是唯一用于证明某个人是否是高级水平的因素。

那么为了进阶为高级水平,我们能为此做哪些准备呢?

高级开发工程师所具备的素质

回顾职业生涯中让我们最钦佩以及尊重的开发者,一般都归结为有如下四方面的素质:

  • 经验
  • 领导力
  • 指导力
  • 技术力

仅仅将其中任何一项作为评判一个人在团队中的表现都不够完善,每个开发者都是独一无二的,某方面素质强,其他方面素质弱些都是正常情况。更重要的是综合上述四个素质来评判某个人能给所在团队带来的进步。

接下来让我们进一步分析各项素质。

经验

谈到经验通常能想到的是工作的年限,但我们知道其实这并不准确而全面。

经验其实和开发者一样,都是与众不同的。比如开发者A在一个需要高密集度解决各种技术挑战的工作环境;与此同时,开发者B在一个有大量时间可以摸鱼的工作环境,可能唯一的工作职责只是每周简单更新下网站而已(当然这不一定是坏事,毕竟每个人都有自己的路要走且需要平衡工作和生活)。同样工作五年,开发者A和开发者B的经验其实是不同的。

那么经验到底意味着什么?

能基于以往的工作辨别新问题的模式

几乎每个开发者开发过程中都经历过(也许是不经意间)某些随机产生的棘手的错误,也许是某些和具体编程语言、工具类、或者操作系统相关的问题,具体是什么没有关系。

通过请教同事也好,Google 也罢,最终你摆平了这个错误!三个月后,你在其他项目中又遇到了类似的问题,这次你都不需要 Google 或者已经知道要 Google 什么了。你早就知道这个问题是怎样的,并且能更快的干掉它!!!

这就是经验带来的变化!能从错误或以往成功的经历中分辨出问题的模式,这就是成长。这些经验可以帮助团队成长,当其他人被卡住时,可以使他们摆脱困境。

能分辨那些你不知道的

能知道那些自己不知道同样很重要,常言道:“学的浅,感觉自己什么都懂;学的越多,反而你可能会发现自己不懂的也越多”。

尽管如此,这不应该被视为可怕的事情。这应该是激烈人心的,我们所熟知的领域有更广阔的天地值得去探索去发现!

初级工程师:“我不知道怎么解决这个问题!如果只是不停的 Google,我永远也找不到答案。😵”

高级工程师:(因为一个问题 Google 或 StackOverflow 了 37个浏览器 tab)

重要的是要意识到这种心智模式将如何影响你的工作及团队中的其他成员。比如,如果你假装什么都知道并且独自做了大量的工作,这并不会帮助到任何人。相反,可能由于你实际并不理解某个需求而导致做了很多无用功,这会影响 sprint 的进度并且让其他团队成员感到沮丧(甚至可能影响到产品的用户)。

无论你是在准备阶段还是在开发期间,都不要害怕去请求协助。open-minded,也许你可能是团队中唯一的高级工程师,但这并不意味着你不能从团队中的其他初、中级工程师学到什么。

尝试去仔细审视目前你所处的阶段,你懂得东西,向哪方面继续深入学习能让受益更多。

领导力

作为团队中的高级开发工程师,一般都被期望能有潜在的领导力。但这并不意味着你需要在项目中扮演 Tech Lead 的角色或者拍板最终决定,而是说你最起码能在一定程度推进项目的进展。

能识大局(bigger picture)

如果你曾和其他团队成员一起开发过项目,应该明白每个项目或 feature 都基本由一系列关联的 stories 来完成。每个 story 都会聚焦在具体的事项上,以实现特定目标。

如果团队中没有任何人明白这些 stories 是如何组装成特定的目标,这将是充满挑战的。作为高级开发工程师,你应该能像拼图一样知道每个不同部件是怎样组合成更大的版图的,并且能理解每个 story 为什么有特定的接受标准。如果你不知道这些,你得知道如何能获取到这些答案并且确保同步给了其他团队成员。

如果你还不确定方向,放下手中的事情,必要时回退一步,仔细想想如何能帮助项目团队实现最终目标。

能协助其他更少经验的团队成员

协助或指引初级开发工程师或其他更少经验的成员,这应该是自然而然的能力。开发者迷失在项目的大局中不知该聚焦在哪儿,这是很普遍的现象。和我们前面讨论的一样,高级开发工程师应该能够对整体项目及不同 stories 如何合理组织有好的想法和把控能力。

协助引导其他团队成员以确保其方向正确。尽管让所有人都明白每个组件如何组成更大图景会很有益处,但有时协助他们专注在手头上的具体任务如何组织会更有意义。在与团队成员合作时,需要意识到这点,无论是通过鼓励他们提出更多问题,还是在 review 代码发现他们偏离方向时提供引导。

指导力

只埋头专注于自己的工作而不用顾虑其他成员在做什么听上去确实挺舒心,但这真的对所有人都有益吗?

能帮助其他团队成员成长

你也许是个“一顶十”的高效率开发者, 但把所有事情都揽在自己手上且没有任何团队协作反而会影响团队整体的效率。比如接手其他同事的工作时,通常你需要花大量的时间和精力去理清上下文。但如果平时有花少量时间和其他成员互相沟通相关工作的话,这个过程会更加顺畅。

“单打独斗”从某种意义上来说并不道德,毕竟没有人想在项目中感到被孤立,尤其是更少经验的工程师。软件工程是个大而复杂的世界,一些小小的引导可以很大程度帮助其他成员提高生产力,且有利于建立一个更多快乐、更少压力的环境。

也行很容易忘记我们曾经也是团队中的一名初级开发工程师的经历,某些事情对于你来说想当然,但这些概念对于其他成员去掌握可能是具有挑战性的。

记住团队成员是一体的,任何大大小小的改进都值得庆祝,当然如果有其他团队成员陷入僵局,记得伸出你的援手。

能分享知识

分享知识是很多团队努力去做的事情,很多时候我们都希望能通过某种方式去完成,但大多数时候却未能如愿。对此,我们能做些什么呢?

请分享你所知的。记得你刚刚又在核心业务逻辑上重复工作了吗?快去花30分钟带所有成员都梳理一遍相关代码吧,如果他们难以理解,记得共享你的屏幕。

这是可以鼓励所有人去做的,不论工程师的经验。通过和团队成员分享你的工作成果,其实也是在归纳总结,温故知新,自然会学到更多。

技术力

我故意将这点放到最后。这是因为,尽管技术能力挺重要,但在成为高级开发工程师的旅途中有更多的方面需要综合,不是仅仅精通于某类技术就行。

能快速上手“新”技术

作为高级开发工程师,通常在其特定领域能比初级开发工程师更快提高生产力。假如你是个 JavaScript 专家,那么你会被期望对 JavaScript 语言的核心原则和模式有较深入的了解。

前面我们讨论过“能分辨那些你不知道的”,认为所有的高级开发工程师知道所有的事情是不现实的。比如我不会因为一个 GoLang 专家不懂 JavaScript 而否认他是个高级开发工程师,但会希望他们在学习其他语言时能懂得更好利用其现有知识储备。

能合理运用模式

某些情况下,你刚刚运用的代码模式不是最新的。这没有关系!在构建出优秀的软件这个目标上,并没有必要要求每个解决方案都是最新且独一无二

因此,我们可以通过以往成功或失败的经历中学习并总结出相关模式,从而更好地运用在团队工作中。

一些成熟的模式(比如 MVC 模式)并不是无故流行起来的,这些都是工程师们不断解决各种软件领域的各类挑战一步步探索出的知识晶体。利用这些知识晶体,我们可以将解决方案应用到日常的工作中。我们不需要重复造轮子,而是更关注在使用成熟的模式解决实际的挑战以及构建出好的产品。

路漫漫其修远兮

在编码生涯中,我们每个人都有自己独特的成长路径。希望我们都能成为更好而全面的开发工程师,并且能明白所做的工作给团队带来的影响力。

最后,一同共勉:路漫漫其修远兮,吾将上下而求索。

JavaScript 与设计模式

JavaScript与设计模式

作为一名有追求的 JavaScript 开发者,你努力编写整洁、健康且可维护的代码。当你解决一些有趣并且特定的挑战时,也许会发现经常对完全不同的问题编写非常类似的代码。也许你还没有意识到,但其实你已经在使用设计模式了。设计模式是软件设计领域对常见问题可复用解决方案

对于任何编程语言,很多可复用的解决方案通常都是被该语言相关社区的开发者们发明并经过大量测试的。由于设计模式是集成了大量开发者经验的解决方案,通常都非常实用并且能帮助我们以更优的方式编码并解决问题。

使用设计模式主要有如下益处

  • 经得起推敲:我们可以肯定设计模式是有用的,因为已经被业界大量的开发者使用了。而且,你几乎可以确信设计模式已经被修订及优化过无数次了。
  • 可复用设计模式与具体的问题没有关系,它是对某类特定问题的成熟的解决方案。
  • 富有表现力设计模式可以优雅的表述解决方案。
  • 便于交流:当开发者们对设计模式都熟悉时,他们可以很容易地对具体问题的潜在解决方案进行交流。
  • 避免后续重构代码:如果应用程序被特意使用设计模式编写了,通常你都不需要后续再对代码进行重构,因为对具体问题采用的合理的设计模式往往都是最优的解决方案。
  • 减少代码量设计模式往往优雅且是最优方案,通常比其他方案要求更少的代码量。

JavaScript 闪亮登场

截止到今天,JavaScript 在 Web 开发领域仍是最流行的编程语言。该语言起初是作为“胶水语言”,在早期的浏览器中配合 HTML 元素使用,也被叫做客户端脚本语言。在那个浏览器仅能展示静态 HTML 元素的年代,JavaScript 的登场无疑是划时代的。和你想的一样,客户端脚本语言的诞生加剧了当时包括微软、网景等大玩家在浏览器开发领域的“浏览器大战”。

当时,每个大玩家都想推动自家实现的脚本语言,比如网景(更具体些,Brendan Eich)创造了 JavaScript,微软创造了 JScript,诸如此类。和你想的一样,这些不同的脚本语言的差别很大,开发者们不得不针对每种浏览器单独进行开发。很快大家都意识到这样下去不可持续,需要制定一套标准及跨浏览器的解决方案以统一及简化 Web 开发过程。这套标准也就是 ECMAScript

ECMAScript 是所有现代浏览器都尽力支持的标准化的脚本语言规范说明,并且有对 ECMAScript 的不同实现(JavaScript 是其中一种比较流行的实现)。自从诞生之日起,ECMAScript 标准化了很多重要的特性,并且随着 ECMAScript 的不断迭代,有了许多不同的版本。浏览器对于 ES6(包括更高版本)的支持尚不完整,所以通常需要转化为 ES5。

JavaScript 有哪些特性?

如果有人问你“什么是 JavaScript?”,你也许会类似这样回答:

JavaScript 是一个高级的、轻量的、解释型的、基于原型式面向对象的多范式编程语言,函数作为其一等公民,其具有动态类型,支持事件驱动、函数式和指令式的编程风格,因作为网站的脚本语言而闻名。

上述定义主要说明 JavaScript 代码具有低内存占用,容易学习与使用,具有类似 C++ 或 Java 的语法风格。作为脚本语言,JavaScript 是解释型的而非编译型的,并且支持包括过程式(属于指令式编程风格的一种)、面向对象与函数式的编程风格。对于开发者来说是一门很灵活的编程语言。

以上,我们了解了所有听起来类似其他语言的特性,下面让我们具体看看一些值得特别关注地区分于其他语言的 JavaScript 特有的特性。

JavaScript 的函数是一等公民

对于很多来自 C/C++ 背景的开发者,刚开始使用 JavaScript 时,函数作为一等公民这个特性可能会有些难以掌握。JavaScript 将函数视为一等公民,意味着你可以像普通变量一样将函数A作为参数传入函数B。

// 将函数作为变量 cb,performOperation 函数执行时会执行传入的函数 cb
const performOperation = (a, b, cb) => {
    const c = a + b;
    cb(c);
}

performOperation(2, 3, result => {
    // 输出 5
    console.log(`The result of the operation is ${result}`);
})

JavaScript 的面向对象是基于原型的

与其他面向对象(OOP)语言一样,JavaScript 支持对象,对于对象我们通常最先想到的术语是类和继承。但不同于其他基于(class-based)的面向对象语言,JavaScript 本身没有类的概念(尽管 ES6 可以使用 class 关键字声明类,但本质是个语法糖),其通过基于原型(prototype-based)的方式达到面向对象的目的。

基于原型的编程是一种面向对象的编程风格,其行为重用(也称为继承)是通过重用已存在对象的原型来达到目的的。后面我们讨论设计模式时会继续深入探讨,此特性在许多 JavaScript 设计模式中都有用到。

JavaScript 的事件循环

你如果有使用过 JavaScript 的经历的话,一定熟悉回调函数这个术语。回调函数其实就是函数A可以作为另一个函数B的参数(记住,JavaScript 将函数视为一等公民),并且函数A会在某个事件发生后执行。回调函数往往被用作订阅类似鼠标点击或者键盘输入等事件。

JS EventLoop

每次事件(会被有关监听者绑定)触发时,相关消息会被发送到一个以 FIFO 的方式同步处理的消息队列中。这就是 JavaScript 的事件循环(Event Loop)机制。更多有关 JavaScript 事件循环机制的细节可参考:《JavaScript中的TaskQueue,Macrotask 与 Microtask》

设计模式分类

如本文开头所描述,设计模式是软件设计领域对常见问题的可复用的解决方案。设计模式有多种归类方式,最流行的分类如下:

  • 创建型设计模式(Creational)
  • 结构型设计模式(Structural)
  • 行为型设计模式(Behavioral)
  • 并发型设计模式(Concurrency)
  • 架构型设计模式(Architectural)

需要注意的是在很多主流分类中,架构型设计模式并不属于设计模式讨论的范畴,而是另外的架构模式。

创建型设计模式

此类设计模式主要处理对象的创建机制,对于特定问题,相较于基础的创建对象的方式能优化对象的创建,基础创建对象的方式可能导致设计问题或增加系统设计的复杂度。创建型设计模式通过某种形式控制对象的创建以解决特定问题,这类设计模式主要有:

  • 工厂方法模式(Factory method)
  • 抽象工厂模式(Abstract factor)
  • 建造者模式(Builder)
  • 原型模式(Prototype)
  • 单例模式(Singleton)

结构型设计模式

此类设计模式主要处理对象间的关系,能保证系统的某部分发生变更后不会影响到系统整体。常见的有:

  • 适配器模式(Adapter)
  • 桥接模式(Bridge)
  • 组合模式(Composite)
  • 装饰器模式(Decorator)
  • 外观模式(Facade)
  • 享元模式(Flyweight)
  • 代理模式(Proxy)

行为型设计模式

此类设计模式关注于系统中不同对象间的高效通信与职责委派。主要有:

  • 责任链模式(Chain Of Responsibility)
  • 命令模式(Command)
  • 迭代器模式(Iterator)
  • 中介者模式(Mediator)
  • 备忘录模式(Memento)
  • 观察者模式(Observer)
  • 状态模式(State)
  • 策略模式(Strategy)
  • 访问者模式(Visitor)
  • 模板方法模式(Template Method)

并发型设计模式

此类设计模式主要涉及处理多线程编程范式。常见的有:

  • 主动对象模式(Active Object)
  • 试探模式(Balking)
  • 屏障模式(Barrier)
  • 双重检查锁定模式(Double-checked Locking)
  • 守护挂起模式(Guarded Suspension)
  • 领导者/追随者模式(Leaders/Followers)
  • 监视器对象模式(Monitor Object)
  • 反应器模式(Reactor)
  • 读写锁模式(Read-Write Lock)
  • 调度者模式(Scheduler)
  • 线程池模式(Thread Pool)
  • 线程本地存储模式(Thread-Local Storage)

架构型设计模式

此类设计模式主要用于代码架构意图。较著名的有:

  • MVC(Model-View-Controller)
  • MVP(Model-View-Presenter)
  • MVVM(Model-View-ViewModel)

JavaScript 常见设计模式

每种设计模式都代表特定类型问题的特定解决方案,并没有所谓全宇宙通用的设计模式来很好地适应各种问题。因此需要我们学习在什么情况下某种设计模式能派上用场并发挥其实际价值,当我们熟悉了设计模式及它们所适用的场景后,就能更容易地确定某种设计模式是否能应用于所要解决的问题。

记住,对问题使用错误的设计模式很可能会导致不期望的效果,比如不必要的代码复杂度、不必要的性能开销等。

以上是我们在应用某个设计模式时需要考虑的问题。接下来我们来探讨一些比较常用并且大多数 JavaScript 开发者需要熟悉的设计模式。

构造器模式(Constructor)

对于典型的面向对象语言,构造器是类中特殊的函数,主要用于初始化对象(对属性赋予默认或者传入的值)。

在 JavaScript 中,创建一个对象通常有以下方式:

// 我们可以使用如下方式创建对象
const instance = {};
// 或
const instance = Object.create(Object.prototype);
// 或
const instance = new Object();

创建对象后,可以使用下列方式对创建的对象添加属性:

// 从 ES3 开始支持
// 点符号
instance.key = "A key's value";

// 中括号
instance["key"] = "A key's value";

// 从 ES5 开始支持
// 使用 Object.defineProperty 设置单个属性
Object.defineProperty(instance, "key", {
    value: "A key's value",
    writable: true,
    enumerable: true,
    configurable: true
});

// 使用 Object.defineProperties 同时设置多个属性
Object.defineProperties(instance, {
    "firstKey": {
        value: "First key's value",
        writable: true
    },
    "secondKey": {
        value: "Second key's value",
        writable: false
    }
});

前面我们提到过 JavaScript 没有从语言层面原生性的支持类(ES6 class 只是语法糖),但通过对函数使用“new”关键字的形式,JavaScript 支持构造器。通过这种方式,我们可以将某个函数作为构造函数来初始化对象的属性,就像使用类中的构造器一样。

// 声明 Person 对象的构造器(函数)
const Person = (name, age, isDeveloper) => {
    this.name = name;
    this.age = age;
    this.isDeveloper = isDeveloper || false;

    this.writesCode = function() {
      console.log(this.isDeveloper? "This person does write code" : "This person does not write code");
    }
}

// 创建对象 Bob
const person1 = new Person("Bob", 38, true);
// 创建对象 Alice
const person2 = new Person("Alice", 32);

// 输出: This person does write code
person1.writesCode();
// 输出: this person does not write code
person2.writesCode();

然而,上述代码还有改进的空间。如果你还记得的话,我们前面有提到过 JavaScript 是基于原型的继承。上述代码的问题出在在 Person 构造器中每次创建对象实例时都会重复定义 writesCode 方法。我们可以通过将 wirtesCode 方法设置为 Person 的原型方法来避免这个问题。

// 声明 Person 对象的构造器
const Person = (name, age, isDeveloper) => {
    this.name = name;
    this.age = age;
    this.isDeveloper = isDeveloper || false;
}

// 扩展函数的原型
Person.prototype.writesCode = function() {
    console.log(this.isDeveloper? "This person does write code" : "This person does not write code");
}

现在,每个 Person 实例都能共享同一个 writesCode 方法。

模块模式(Module)

就独特性而言,JavaScript 永远不会停止让你感到惊讶。JavaScript 另一个独特之处(对于面向对象语言来说)就是其不支持访问修饰符(access modifiers)。而其他典型的面向对象语言,用户是可以在声明一个类时确定其成员的访问权限。为此,JavaScript 开发人员鼓捣出来模仿这些行为的方式。

在我们正式进入 JavaScript 的模块模式前,先讨论下其闭包(closure)的概念。闭包是指一个能访问其父级作用域的函数(即使在其父级函数结束运行后),可以帮助我们通过作用域来模仿访问修饰符的行为。举个例子:

// 我们使用一个立即执行函数来声明 counter 这个私有变量
const counterIncrementer = (
  () => {
    let counter = 0;

    return () => {
        return ++counter;
    };
    }
)();

// 输出 1
console.log(counterIncrementer());
// 输出 2
console.log(counterIncrementer());
// 输出 3
console.log(counterIncrementer());

如上面的例子,通过使用立即执行函数(IIFE),我们将 counter 变量绑定到一个已经执行完闭的函数中,而且仍然可以通过其返回的子函数将 counter 变量递增。以上我们已通过操作作用域的方式将 counter 私有化了,无法通过外部直接访问到 counter 变量。

使用闭包,我们在创建对象时就可以为其定义私有及公有的部分。这其实就是模块,当我们需要隐藏对象的某些细节并且仅展示特定对外接口时,这会非常实用。如下面的例子:

// 通过使用闭包我们暴露了一个对象作为对外接口
// 可以管理私有成员 objects 数组
const collection = (() => {
    // 私有成员
    const objects = [];

    // 公有成员
    return {
        addObject: (object) => {
            objects.push(object);
        },
        removeObject: (object) => {
            const index = objects.indexOf(object);
            if (index >= 0) {
                objects.splice(index, 1);
            }
        },
        getObjects: () => {
            return JSON.parse(JSON.stringify(objects));
        }
    };
})();

collection.addObject("Bob");
collection.addObject("Alice");
collection.addObject("Franck");
// 输出 ["Bob", "Alice", "Franck"]
console.log(collection.getObjects());
collection.removeObject("Alice");
// 输出 ["Bob", "Franck"]
console.log(collection.getObjects());

模块模式很好地将对象私有和公有的部分清晰地分离开来。然而,当你希望改变成员的可见性时,由于访问公有部分及私有部分的性质不同,你需要修改所有用到这些成员的代码。另外,在对象创建添加的方法是无法访问到对象的私有成员的。

揭示模块模式(Revealing Module)

此模式是上述模块模式的增强版本,主要的不同是我们将所有对象的逻辑放到了模块的私有部分,然后通过返回匿名对象的方式暴露出公有的部分。我们可以在将私有成员映射到公有成员时改变他们的命名。

// 我们将所有对象的逻辑都放到私有成员中,然后暴露出一个映射了私有成员的匿名对象
const namesCollection = (() => {
    // 私有成员
    const objects = [];

    const addObject = (object) => {
        objects.push(object);
    }

    const removeObject = (object) => {
        var index = objects.indexOf(object);
        if (index >= 0) {
            objects.splice(index, 1);
        }
    }

    const getObjects = () => {
        return JSON.parse(JSON.stringify(objects));
    }

    // 公有成员
    return {
        addName: addObject,
        removeName: removeObject,
        getNames: getObjects
    };
})();

namesCollection.addName("Bob");
namesCollection.addName("Alice");
namesCollection.addName("Franck");
// 输出 ["Bob", "Alice", "Franck"]
console.log(namesCollection.getNames());
namesCollection.removeName("Alice");
// 输出 ["Bob", "Franck"]
console.log(namesCollection.getNames());

揭示模块模式与其他模块模式不同点主要在于公有成员是如何引用的,其更容易使用与修改。然后揭示模块模式在某些特殊场景也会比较脆弱,比如使用该模式创建的对象作为继承链上的原型时,会有如下问题:

  • 如果私有函数被公有函数引用了,我们不能重写这个公有函数,这是因为私有函数会继续指向之前的函数实现,并可能导致系统出 bug。
  • 如果公有成员指向了私有变量,当我们尝试从模块外重写这个公有成员时,其他函数仍会引用该私有变量值,这可能会导致系统出 bug。

单例模式(Singleton)

该模式的应用场景是我们仅需要类的一个实例时,比如我们需要一个包含某类配置信息的对象。在这类场景中,我们需要在代码中不同部分用到这些配置时都单独创建一个对象。

const singleton = (() => {
    // 仅被初始化一次的私有值
    let config;

    const initializeConfiguration = (values) => {
        this.randomNumber = Math.random();
        values = values || {};
        this.number = values.number || 5;
        this.size = values.size || 10;
    }

    // 暴露用于获取单例值的统一处理函数
    return {
        getConfig: (values) => {
            // 确保仅被初始化一次
            if (config === undefined) {
                config = new initializeConfiguration(values);
            }

            return config;
        }
    };
})();

const configObject = singleton.getConfig({ "size": 8 });
// 输出 number: 5, size: 8, randomNumber: 某个随机值
console.log(configObject);
const configObject1 = singleton.getConfig({ "number": 8 });
// 输出 number: 5, size: 8, randomNumber: 和上面相同
console.log(configObject1);

需要注意的是,在每次获取实例时都需要确保其有且仅有同一个实例。

观察者模式(Observer)

当我们需要优化系统中不同部分的通信时,观察者模式将是个非常有用的工具,能很好地降低对象间的耦合性。

观察者模式有很多不同版本,其最基础的形式是拥有两个主要部分,分别为 SubjectObserver

Subject 处理有关 Observers 订阅的某个主题的所有操作。这些操包括:为 Observer 订阅特定主题,为 Observer 取消订阅特定主题,通知所有 Observers 关于某个特定主题的事件发布。

观察者模式演化出发布者/订阅者模式(publisher/subscriber),后面有代码示例。相对于典型的观察者模式发布者/订阅者模式主要的不同在于其更进一步地降低耦合性。在观察者模式中,Subject 持有所有 Observers 的引用并且直接通过 Subject 对象本身直接调用 Observer 的方法进行通知。而在发布者/订阅者模式中,发布者订阅者之间是通过频道(或者说经纪人,从数据结构角度看通常是个消息队列)建立通信渠道的,也就是发布者和订阅者间互不认识(完全解耦)。发布者在成功发布了某个事件到频道中就可以进行后续的业务逻辑,而无需关心订阅者

const publisherSubscriber = {};

// container对象处理订阅及发布
((container) => {
    // id代表某个主题的唯一订阅ID
    const id = 0;

    // 订阅特定主题并传入主题触发后的处理函数
    container.subscribe = (topic, f) => {
        if (!(topic in container)) {
          container[topic] = [];
        }

        container[topic].push({
            "id": ++id,
            "callback": f
        });

        return id;
    }

    // 针对主题每个订阅都有其独一无二的ID,我们可以用于取消订阅
    container.unsubscribe = (topic, id) => {
        const subscribers = [];
        for (const subscriber of container[topic]) {
            if (subscriber.id !== id) {
                subscribers.push(subscriber);
            }
        }
        container[topic] = subscribers;
    }

    container.publish = (topic, data) => {
        for (const subscriber of container[topic]) {
            // 执行订阅时传入的处理函数时
            subscriber.callback(data);
        }
    }

})(publisherSubscriber);

const subscriptionID1 = publisherSubscriber.subscribe("mouseClicked", (data) => {
    console.log("I am Bob's callback function for a mouse clicked event and this is my event data: " + JSON.stringify(data));
});

const subscriptionID2 = publisherSubscriber.subscribe("mouseHovered", (data) => {
    console.log("I am Bob's callback function for a hovered mouse event and this is my event data: " + JSON.stringify(data));
});

const subscriptionID3 = publisherSubscriber.subscribe("mouseClicked", (data) => {
    console.log("I am Alice's callback function for a mouse clicked event and this is my event data: " + JSON.stringify(data));
});

// 执行3次 console.log
publisherSubscriber.publish("mouseClicked", {"data": "data1"});
publisherSubscriber.publish("mouseHovered", {"data": "data2"});

// 取消订阅
publisherSubscriber.unsubscribe("mouseClicked", subscriptionID3);

// 执行2次 console.log
publisherSubscriber.publish("mouseClicked", {"data": "data1"});
publisherSubscriber.publish("mouseHovered", {"data": "data2"});

使用这个模式的缺点是其加大了测试系统不同部分的难度,比如没有优雅的方式可以测试订阅者部分是否符合预期。

中介者模式(Mediator)

当谈到将系统解耦时,中介者模式或许也是个非常有用的工具。

中介者是一个对象,它作为中介者被用作系统不同部分间的通信并处理它们间的工作流(workflow)。这里特别强调了处理工作流,为什么呢?

因为中介者模式与上面谈到的发布者/订阅者模式有很多的相同点,你也许会问自己:“嗯......这两种设计模式都用于实现对象间更好的通信.......那他们有何不同呢?🤔”

主要不同在于中介者模式同时处理了工作流,而发布者/订阅者模式使用“即发即弃(fire and forget)”的方式处理通信。发布者/订阅者模式可以认为是个简单的事件(消息)聚合器,它只关心为发布者发送特定事件并确保对应的订阅者知道该事件被触发了,并不关心事件触发后的处理逻辑。

中介者模式的一个不错的例子是作为向导类型的接口。假设你开发的系统有较复杂的注册流程,并且需要用户填写大量的信息时,通常将这些流程拆解为一系列的步骤是个不错的实践。通过这种方式,代码会更简洁(更易维护),并且对于用户来说更友好(不需要仅为了完成注册而填写大量的信息)。此时中介者作为处理注册步骤的对象,对于不同用户潜在的不同的注册过程,需要考虑各种可能的工作流(注册流)。

这种模式的缺点是我们在系统中引入了一个潜在的错误点,假如中介者挂了,整个系统可能会停止工作。

原型模式(Prototype)

和本文前面谈到的一样,JavaScript 底子里不支持类,其通过基于原型的编程方式进行对象继承的。这使我们能够为对象创建其原型对象,如下面的例子:

const personPrototype = {
    sayHi: function (){
        console.log("Hello, my name is " + this.name + ", and I am " + this.age);
    },
    sayBye: function () {
        console.log("Bye Bye!");
    }
};

const Person = (name, age) => {
    name = name || "John Doe";
    age = age || 26;

    function constructorFunction(name, age) {
        this.name = name;
        this.age = age;
    };

    constructorFunction.prototype = personPrototype;

    const instance = new constructorFunction(name, age);
    return instance;
}

const person1 = Person();
const person2 = Person("Bob", 38);

// 输出: Hello, my name is John Doe, and I am 26
person1.sayHi();
// 输出: Hello, my name is Bob, and I am 38
person2.sayHi();

需要注意到,基于原型的继承是可以提升性能的。因为创建的对象会保留对原型对象的引用,而无需对每个对象都进行重复声明。

命令模式(Command)

当我们需要通过从某个集中的对象触发命令的方式来降低对象间的耦合时,命令模式会很有帮助。比如,应用中使用了大量的 API 服务的调用,当我们的 API 服务发生变更时,可能要根据已变更的 API 改动很多地方。这种情况下我们需要实现一个抽象层,将 API 服务的调用从直接使用的对象中抽离出来。这样我们可以避免在 API 服务发生变更时需要同时修改多个地方,因为实际请求 API 服务只存在于一个地方。

和其他任何模式一样,我们需要确切的知道在什么情况下才真的需要使用这个模式。我们需要实际问题做出取舍,因为增加了抽象层可能会降低性能,但却可能提高代码的清晰度及更易维护。

// 实际执行命令的对象
const invoker = {
    add: (x, y) => x + y,
    subtract: (x, y) => x - y,
}

// 执行命令的抽象层,对于调用者来说是 invoker 对象的接口
const manager = {
    execute: (name, ...args) => {
        if (name in invoker) {
            return invoker[name](...args);
        }
        return false;
    }
}

// 输出 8
console.log(manager.execute("add", 3, 5));
// 输出 2
console.log(manager.execute("subtract", 5, 3));

外观模式(Facade)

外观模式用于隐藏系统的复杂性,在内部实现及客户端间建立抽象层,并向客户端提供了一个可以访问系统的接口。

典型的例子是前端曾经一度流行的 JQuery 库中的 selector 选择器,其对外隐藏了对 DOM 的相关操作。

jQuery(".parent .child div.span")

其大大简化了选择 DOM 元素的过程,尽管表面上看起来很简单,但其内部是做了大量的复杂逻辑。

总结

任何中高级 JavaScript 开发者都需要意识到设计模式是非常有用的工具,熟悉有关设计模式的细节会非常有用,能让我们在项目的生命周期中节省大量宝贵时间,尤其是在维护阶段。

由于篇幅所限,本文只列举了部分常见的 JavaScript 设计模式。我们日常开发过程中也要下意识的去应用设计模式并不断积累这类经验,多学、多思、多运用,相信你一定能成为更好的开发者。

SOLID 原则浅析

SOLID原则

相关术语

在正式进入 SOLID 的探讨之前,我们最好先了解如下几个术语:

最少知识原则强调的是每个代码单元(包括但不限于类、函数、对象、模块)都应该对其他代码单元有尽可能少的知识,这能帮助提升代码的可重用性及可维护性。

耦合也称块间联系。指软件系统结构中各模块间相互联系紧密程度的一种度量。模块之间联系越紧密,其耦合性就越强,模块的独立性则越差。

内聚标志一个模块内各个元素彼此结合的紧密程度,它是信息隐蔽和局部化概念的自然扩展。内聚是从功能角度来度量模块内的联系,一个好的内聚模块应当恰好做一件事。它描述的是模块内的功能联系。

我们的软件由大量的类或对象单元组成,它们将数据及其相关的方法封装在一起。对象间应该是低耦合的,并且尽可能减少彼此间的依赖,每个对象尽可能对其他对象有更少的了解。特性(feature)与模块也应如此。S.O.L.I.D 就是用于帮助我们达成这些目的一些原则。

什么是 SOLID 原则?

SOLID 原则首先由著名的计算机科学家 Robert C·Martin (著名的Bob大叔)由 2000 年在他的论文中提出。但是 SOLID 缩略词是稍晚由 Michael Feathers 先使用的。Bob大叔也是畅销书《代码整洁之道》和《架构整洁之道》的作者,也是 "Agile Alliance" 的成员。

SOLID 指的是一组软件设计原则,可以指导我们构建我们的方法和类,以提升可靠性、可维护性和可适应性。

如果你过去编写的代码无法满足当前新的需求,更改这些代码以满足这些需求可能会很昂贵。我们需要记录下那些地方需要修改,不断地去完善,这个过程中不应该增加更多的缺陷或使其变得更难扩展。

SOLID 其实是多个英文首字母的组合,每个字母代表:

  • S: Single Responsiblity Principle(SRP,单一职责/功能原则)
  • O: Open-Closed Principle(开闭原则)
  • L: Liskov-Substitution Principle(里氏替换原则)
  • I: Interface Segregation Principle(接口隔离原则)
  • D: Dependency Inversion Principle(依赖反转原则)

单一职责原则

每个类或函数应该只有一个变化的原因,并且只做好一件事情。需要注意如下几点:

  • 不要将函数放在会因各种各样原因而改变的类中
  • 基于使用它的用户考虑其职责(变化的原因)
  • 类应该是低耦合且高内聚的

举个实际的例子,比如我们开发的企业应用需要计算技术部、财务部等不同部门的薪水及工时并将记录存储至数据库中。我们最好能将不同部门的不同计算逻辑分离(抽象)开来。

class Employee {
  public calculateSalary (): number { ...some code }
  public hoursWorked (): number { ...some code }
  public storeToDB (): any { ...some code }
} // 没有将不同部门的不同计算逻辑分离,违背了SRP原则:

上述代码中对不同部门的所有业务逻辑都写到一块了,因其中一个部门的代码改动会同时影响到其他部门(或者说我们尝试在同一个类中处理,会导致代码中出现各种嵌套的 if-else/switch 语句,不利于代码维护)。

abstract class Employee {
  abstract calculateSalary (): number;
  abstract hoursWorked (): number;
  protected storeToDB ():any { ...some code }
}

// 迫使开发者根据抽象类为不同的部门实现对应的类,便于维护并且减少了潜在的代码提交发生冲突的可能
class Technical extends Employee {
  calculateSalary (): number { ...some code }
  hoursWorked (): number {...some code }
}

class Finance extends Employee {
  calculateSalary (): number {...some code}
  hoursWorked (): number {...some code}
}

仍然对如何组织一个类有困惑?建议从类的使用者(用户/角色)的角度去考虑,并结合实际业务逻辑。

开闭原则

代码中的实体(类、模块、函数等)应该对扩展开放对修改关闭。这个原则想要表达的是:我们应该能在不动已经存在代码的前提下添加新的功能。这是因为当我们修改存在的代码时,我们就面临着创建潜在 bug 的风险。因此,如果可能,应该避免碰通过测试的(大部分时候)可靠的生产环境的代码。

违背该规则的代码的味道:代码中存在大量 if/switch 语句。为何我们要尽量避免大量的 if/switch 语句?因为多种执行流更容易导致 bug 的产生。

里氏替换原则

里氏替换原则描述的是子类应该能替换为它的基类。意思是,给定 class B 是 class A 的子类,在预期传入 class A 的对象的任何方法传入 class B 的对象,方法都不应该有异常。这是一个预期的行为,因为继承假定子类继承了父类的一切。子类可以扩展行为但不会收窄

来看个例子:

Class Rectangle { // 长方形
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

  setWidth(width) {
    this.width = width;
  }

  setHeight(height) {
    this.height = height;
  }

  area ( ) {
    return this.width * this.height;
  }
}

Class Square extends Rectangle { // 正方形
  setWidth(width) {
    this.width = width
    this.height = width;
  }

  setHeight(height) {
    this.height = height;
    this.width = height;
  }
}

const rectangle = new Rectangle(10, 2)
const square = new Square(5, 5)

function increaseRectangleWidth(rectangle) {
    rectangle.setWidth(rectangle.width +1)
}
increaseRectangleWidth(rectangle) // 新的面积为:11 * 2 = 22
increaseRectangleWidth(square) // 新的面积为:6 * 6 = 36

嘿,问题来了:上述代码中的正方形作为长方形的子类,他们符合里氏替换原则吗?

🤔......

答案是否定的!原因是正方形在设置宽度时同时设置了高度(将父类的行为收窄了)。

解决方案:可以将正方形与长方形的共同点进一步剥离出来,基于此创建一个共同的父类(如 Shape),使父类更为通用。

Class Shape {
  constructor(width, height) {
    this.width = width;
    this.height = height;
  }

    area(): void {
    return this.width * this.height;
  }
}

接口隔离原则

该原则旨在通过将大型接口拆解为更小的部分以减少大型接口带来的负面影响,很多客户端特定的接口优于一个多用途接口,理念上有些类似单一职责原则。客户端不应该强制实现他们不需要的函数。

/**
 * 正面例子
 */
export class SummaryPageComponent implements OnInit {
    // 实现 OnInit 接口
}

export interface OnInit { // 小而特定的接口
    ngOnInit(): void; // 只有一个方法
}

/**
 * 反面例子
 */
export class SummaryPageComponent implements LifeCycles {
    // 尽管有些方法不需要,但仍然要实现它
}

export interface LifeCycles {
  // 太多方法
  ngOnInit(): void;
  ngOnChanges(changes: SimpleChanges): void;
  ngDoCheck(): void;
  ngOnDestroy(): void;
  ...
}

依赖反转原则

依赖倒置原则描述的是我们的 class 应该依赖接口和抽象类而不是具体的类和函数。

举个支付相关的例子:

class GooglePayService {
  constructor (googlePayInstance) {
    this.gps = googlePayInstance;
  }
  pay (to, amount ) {
    ...some code
  }
}

10个月后,你的上级告诉你需要将支付服务修改为 PhonePay 而不是 GooglePay(或两者都需要支持),你的代码需要支持这种扩展:

type PaymentTransaction = 'Success' | 'Failure' | 'Bounced'

interface IPaymentTransactionResult {
  result: PaymentTransaction;
  message?: string;
}

interface IPaymentService {
    pay(to: string, amount: number): Promise<IPaymentTransactionResult>
}

class GooglePayService implements IPaymentService {
  pay(to: string, amount: number): Promise<IPaymentTransactionResult> {
    ...some code
  }
}

class PhonePayService implements IPaymentService {
  pay(to: string, amount: number): Promise<IPaymentTransactionResult> {
    ...some code
  }
}

然后我们可以将以上两个类“依赖注入”到需要使用的类中,通过引用接口而不是具体的实现的方式。

class CreateUserController extends BaseController {
  constructor (paymentService: IPaymentService) {
    this.paymentService = paymentService;
  }

  protected proceedPayTransaction (): void {
    // api 处理逻辑...
    // 支付
    this.paymentService.pay(userId, amount);
  }
}

现在,可以使用任何需要的支付服务,或者添加其他支付服务。

const phonePayService = new PhonePayService();
const createUserController = new CreateUserController(phonePayService);
createUserController.proceedPayTransaction();

const googlePayService = new GooglePayService();
const createUserController = new CreateUserController(googlePayService);

总结

在代码中运用 SOLID 原则可以帮助我们提升代码的可复用性、可适应性、可读性、可维护性,并且更利于测试。

日常编码其实也是个不断优化代码的过程,随着业务需求的复杂度提升,代码“熵”增不可避免,我们能做的就是努力通过使用 SOLID 原则或相关设计模式让“熵”减少或减缓其增加的步伐。

Git flow —— 一种常用的 git 分支模型

git flow

背景

目前团队在 git 实践上还没有一套统一的规范,且存在以下影响开发效率的情况:

  • 不同团队成员对于 git 的使用未达成一致,导致在实际开发协助中需要花费更多额外的时间和精力解决各种意想不到的问题。
  • 不同项目间使用的 git 实践也不一致,导致在切换项目时存在一定的上下文损耗,且更容易出错。

因此,亟需一套大家达成一致的通用的 git 实践以解决上述问题,从而提升效率。

为什么使用 git flow ?

结合团队项目的实际情况以及各主流 git 分支模型的特点,推荐使用最为知名且经过大量项目验证的 git flow 模型。

什么是 git flow ?

Git flow是由 Vincent Driessen 于2010年提出的 git branching workflow,主要特点是基于两个 long-live 分支以及一些用于支援开发周期的 supporting 分支进行分支管理。

long-live 分支

主要包含 masterdevelop 这两个存在于整个研发周期的固定分支。

  • master 分支:对应的是已经在生产环境上的代码。
  • develop 分支:包含下次将要部署到 production 的变更。

当 develop 分支上的代码稳定且可以发版时,其中所有的变更应该通过某种形式(一般是从 develop 分支 checkout out 出对应的 release 分支进行进一步的验证)merge 到 master 分支上,之后再 tag 对应的版本号。

supporting 分支

除了上述 long-live 分支,我们还会使用一些 supporting 分支以支援日常的开发场景。不同于 long-live 分支,supporting 分支都是为了特定的目的而存在,因此这些分支的存活周期是有限的。

我们主要使用的 supporting 分支类型有:

  • Feature 分支:从 develop 分支 checkout 出来,用于新功能的开发。
  • Release 分支:从 develop 分支 checkout 出来,用于部署到 production 前的预发布及验证,另外可以解放 develop 分支以进行后续功能的迭代。
  • Hotfix分支:从 master 分支 checkout 出来,用于快速修复 production 环境发现的 bug。

Feature 分支

Feature branch

  • 只应存在于开发者个人的 repo,不应出现在 upstream repo
  • develop 分支 checkout,必须 merge 到 develop 分支
  • 命名规范:feat/[ticket-no]-[simple-description]

Release 分支

  • 需存在于 upstream repo
  • 仅可基于该分支进行 bug fix
  • develop 分支 checkout(时机为 develop 分支能反映出下次上 production 的状态),必须 merge 到 master 分支(当有在 release 分支进行 bug fix 时,也必须 merge 回 develop 分支)
  • 命名规范:release/[release-no]
  • 版本号:*.0.* -> *.1.01.*.* -> 2.0.0

Hotfix 分支

hotfix branch

  • 只应存在于开发者个人的 repo,不应出现在 upstream repo
  • master 分支 checkout,必须 merge 回 develop 分支与 master 分支(若此时存在 release 分支,则应将 hotfix 分支先 merge 到 release 分支,再将 release 分支 merge 回 develop 分支)
  • 命名规范:hotfix/[issue-no]-[simple-description]
  • 版本号:*.*.1 -> *.*.2

其他约定

  • master, develop, release 分支上的迭代必须通过提 PR 的方式进行,禁止直接修改这些分支。
  • 合并 PR 时使用 create a merge commit 的方式(因为其他方式会导致丢失被合并分支上的 commit 信息,后续若继续使用此原始分支提交 PR,会导致旧 PR 的改动仍然出现在新 PR 中的情况,详情请参考 github 官方文档)。

参考资料

JavaScript中的TaskQueue,Macrotask 与 Microtask

通常,JavaScript会通过使用事件机制或timer的方式以达到在特定事件或时间调度执行某段代码(块),这种类型的异步通常在JavaScript中称为Event loop(事件循环)机制。本文,我们将探讨事件循环机制的工作原理,并演示其任务队列的执行过程。

JavaScript中的Event Loop(事件循环)与Call Stack(调用栈)

由于是单线程的,JS使用事件循环的概念来创建异步运行多个任务(我们的JS代码只运行在一个线程中,而JS使用事件循环异步运行代码)。我们将listener(监听器)附加到事件上,因此无论何时触发事件,附加到该事件的回调代码都会被执行。在进一步深入之前,让我们先了解一下JavaScript引擎是如何工作的。

JavaScript引擎由Stack(栈),Heap(堆)及Task Queue(任务队列)组成。

  • Stack

Stack是一个类似数组的结构,用于跟踪当前正在执行的函数。

function m() {
    a()
    b()
}
m()

我们有一个m函数,该函数在函数体中调用了a函数和b函数。在开始执行时,内存中m函数的地址将被压入call stack(调用栈)。同样,JavaScript引擎在执行a函数和b函数前也会将它们的地址压入调用栈。

先等等,你是否会有这样的疑问:为什么JavaScript引擎要存储函数地址及其参数到调用栈呢?

在底层(以x86汇编语言为例),CPU会利用EAX,EBX,ECX,ESP,EIPregisters(寄存器)来临时存储变量并运行我们的程序(前提是已经加载到内存中)。其中EAXEBX用于计算,ECX用于counter job(计数器作业,如存储for循环的次数)。ESP(堆栈指针)保存堆栈的当前地址,EIP(指令指针)保存要执行的程序的当前地址。

RAM                 EIP = 10
0 |     |           ESP = 21
1 |a(){}|
2 |     |             Call Stack
3 |b(){}|             14|   |
4 |     |             15|   |
5 |     |             16|   |
6 |m(){ |             17|   |
7 | a() |             18|   |
8 | b() |             19|   |
9 |}    |             20|   |
10|m()  |             21|   | 

以上是我们的程序运行时在内存中表示的草图,我们看到程序被加载,然后是调用堆栈、ESP和EIP。程序的入口是m(),这就是为什么EIP是10(语句在内存中的位置)。在执行期间,CPU通过查看EIP来知道从哪里开始执行。

每当调用函数时,执行都会跳转到内存中的函数并从那里执行。 然后,在函数运行完成时,必须从其跳转的位置继续上一个函数,因此返回地址必须保存,调用栈就是用于解决这个问题的。 在每个函数调用中,EIP中的当前值都被推送到调用堆栈中。 在我们的示例中,当调用a()时,我们的调用堆栈如下所示:

RAM                   EIP = 1
  0 |     |           ESP = 19
➥ 1 |a(){}|
  2 |     |             Call Stack
  3 |b(){}|             14|   |
  4 |     |             15|   |
  5 |     |             16|   |
  6 |m(){ |             17|   |
  7 | a() |             18|   |
  8 | b() |             19|   |
  9 |}    |             20| 7 |
  10|m()  |             21| 10| 

a()执行完成后,调用栈会将栈顶的7弹出并赋值给EIP,来达到告知CPU继续执行内存地址为7之后的指令。

为什么函数执行参数也要推入调用栈?其实在执行带参数的函数时,该函数使用EBP寄存器从堆栈中获取值,这些值就是它的参数。因此,在调用方函数调用一个函数之前,它必须首先推送被调用方函数要访问的参数,然后推送EIP和ESP地址。

  • Heap

通常在new一个Objects(对象)时,会将新创建的对象分配到堆中。

const apple = new Fruit('apple', 'very_tasty');

上述代码将在堆上创建一个Fruit对象,并将地址返回给apple变量。由于堆本质上不是有序的,因此操作系统必须找到一种方法来实现内存管理,以防止内存泄漏。

  • Task Queue

后续由JavaScript引擎处理的任务会进入任务队列。事件循环机制会不断检查调用栈是否为空,为空的话会继续执行任务队列中排队的所有回调。

Microtask(微任务)与Macrotask(宏任务)

上面我们探讨了JavaScript引擎的工作方式以及任务队列的基本作用。当我们进一步探讨任务队列时,发现 任务进一步被细分为微任务宏任务

事件循环的每次循环:

while (eventLoop.waitForTask()) {
  eventLoop.processNextTask()
}

每次循环中只处理一个宏任务(任务队列是宏任务队列), 完成此操作后,将在同一循环内处理微任务队列中入队的所有微任务。 这些微任务可以入队其他微任务,这些微任务将一直运行直到微任务队列为空。

while (eventLoop.waitForTask()) {
  const taskQueue = eventLoop.selectTaskQueue()
  if (taskQueue.hasNextTask()) {
    taskQueue.processNextTask()
  }

  const microtaskQueue = eventLoop.microTaskQueue
  while (microtaskQueue.hasNextMicrotask()) {
    microtaskQueue.processNextMicrotask()
  }
}

举个实际栗子以更好帮助我们了解微任务及宏任务的处理机制:

// example.js
console.log('script start');

setTimeout(function() {
  console.log('setTimeout');
}, 0);

Promise.resolve().then(function() {
  console.log('promise1');
}).then(function() {
  console.log('promise2');
});

console.log('script end');

当我们运行上述代码时,将会得到下述结果:

script start
script end
promise1
promise2
setTimeout

PS:宏任务可通过setTimeoutsetIntervalsetImmediateevent等方式入队;微任务通过process.nextTickPromisesMutationObserver等方式入队。

上面提到每次事件循环都会先处理一个宏任务,之后再处理微任务队列中的所有微任务。因此你也许会觉得setTimeout应该先于promise1与promise2打印,毕竟似乎没有其他宏任务先于setTimeout产生的宏任务入队?

JavaScript是事件驱动的,因此除非产生了事件,没有代码会在JavaScript引擎中运行。其实在执行任何JS文件前,JavaScript引擎都会将其中的内容包装进一个函数,并且将该函数绑定了startlaunch事件。然后JavaScript引擎会主动触发start事件,该事件的回调函数(其实就是包装代码的函数)将会被加入到宏任务队列中。

总结

  • 任务从任务队列中获取
  • 从任务队列中获取的任务是宏任务,而不是微任务
  • 微任务在当前任务结束时进行处理,在下一个宏任务处理前会处理完微任务队列中所以微任务。
  • 微任务可以入队其他微任务,但微任务队列中所有的微任务都会在下一轮宏任务开始前执行完。
  • UI渲染运行在所有微任务执行完成后。

参考资料

Node.js 16 初体验

Node.js 16已经于4月20日发布,主要包括如下更新:

  • 升级V8引擎至9.0
  • 预构建的Apple silicon二进制文件以支持使用苹果芯片的Mac系统
  • 稳定部分API

详细更新日志可参考:https://nodejs.org/zh-cn/blog/release/v16.0.0/

Node.js发布时间表

可以在官网下载最新发布体验,或者使用版本管理工具(本文以跨平台的nvs为例)。

# 下载V16的Node.js
nvs add 16

# 在当前终端使用V16的Node.js(推荐)
nvs use 16
# 或者:设置全局默认的Node.js至V16
nvs link 16

查看node & npm版本

Node.js 16将会替代15并于接下来半年成为当前发布线(期间可能会有部分非紧要的代码更新),之后将于十月份进入长期支持(LTS)阶段并将该版本命名为Gallium。PS:V12会在2022年4月结束LTS阶段,V14为2023年4月,V10将于本月结束LTS阶段。

V8引擎的升级

每次V8引擎的升级都会带来性能的调整与提升以及支持最新的JavaScript语言特性,因此Node.js会常态化更新V8版本,Node.js 16使用V8 9.0(15使用的是V8 8.6)。

这次V8的升级带来了ECMAScript RegExp比对索引功能,能提供字符串的开始与结束索引。当正则表达式带有/d标签时,可以通过indices属性访问到索引数组。

RegExp match indice范例

更多V8更新请参考:https://v8.dev/

稳定Timers Promises API

Timers Promises API提供了可返回Promise对象的timer函数(无需再将timer通过util.promisify进行Promise化),该API于Node.js 15新增(本次发布将其从experimental状态更新为stable状态)。

import { setTimeout } from 'timers/promises';

async function run() {
  const res = await setTimeout(3000, 'fullFilledValue');
  console.log(`Get result=>${res} after 3s`);
}

run(); // 3s后输出:Get result=>fullFilledValue after 3s

其他特性

新的编译器与平台最低要求

Node.js针对不同的平台提供预构建的二进制文件,对于每个主要版本,在适当的情况下评估并提出最少的工具链。

Node.js v16.0.0是首个为apple芯片交付预构建二进制文件的版本,虽然将分别为Intel(darwin-x64)和ARM(darwin-arm64)架构提供压缩包,但macOS安装程序(.pkg)将作为“fat”(多架构)二进制文件提供。在基于Linux的平台上,构建Node.js 16的最低GCC级别将为GCC 8.3。详情请参考Node.js BUILDING.md文件

弃用

Node.js项目使用名为CITGM(Canary in the Goldmine)的工具,测试任何重大变化(包括弃用)对大量流行生态系统模块的影响,以在执行这些变化之前提供更多的参考。Node.js v16主要的弃用包括对多个核心模块内部执行process.binding()的运行时弃用,如process.binding(‘http_parser’)

云原生时代,拥抱微服务(三)—— 使用Kubernetes包管理器Helm

前文我们已经将Kubernetes集群搭建好,并且将应用容器化。然而作为开发者,在项目部署中依然觉得直接去管理各种Kubernetes对象是件非常费时且枯燥的事情(要直接去管理各种Kubernetes资源及相关联的标签与选择算符命名空间等)。

很多时候,当某处繁琐且重复时,便是优化开始的地方。 —— 皮皮叨叨

Helm是什么

Helm是对Kubernetes资源部署进一步的抽象,通俗地讲就是个Kubernetes包管理器,相当于Linux中的yum或apt。Helm通过直接部署将Kubernetes资源打包后的Charts,能够简化微服务应用的部署,并且能方便地更新与回滚应用。Helm Chart通过模板的方式部署应用程序。

Helm Chart具有如下目录结构:

YOUR-CHART-NAME/
 |
 |- .helmignore 
 | 
 |- Chart.yaml 
 | 
 |- values.yaml 
 | 
 |- charts/ 
 |
 |- templates/

其中:

  • .helmignore:声明所有在Chart打包时需忽略的文件,类似.gitignore功能。
  • Chart.yaml:存放该Chart的基本信息,如Chart名,版本号等
  • values.yaml:定义所有需要注入模板中的值,该文件中的值可被命令行参数覆盖。
  • templates:此文件夹存放该Chart所需要的Kubernetes资源清单(包括但不限于service、ingress、deployment等),其中的yaml文件使用golang 模板编写,value可以通过Values.yaml中定义的值注入。

小试牛刀(使用Helm安装WordPress)

  1. 安装Helm:

    sudo snap install helm --classic
    # 或
    microk8s.enable helm
  2. 新增环境变量(KUBECONFIG)

    安装完后直接运行helm ls

    运行 helm ls 命令

    会报“Kubernetes集群不可达”的错误。这是因为:helm v3版本不再需要Tiller,而是直接访问ApiServer来与k8s交互,通过环境变量KUBECONFIG来读取存有ApiServre的地址与token的配置文件地址,默认地址为~/.kube/config。而microk8s的相关配置文件路径为/var/snap/microk8s/current/credentials/client.config。解决方法:

    # 在.bashrc文件末尾追加新的KUBECONFIG
    echo export KUBECONFIG=/var/snap/microk8s/current/credentials/client.config >> ~/.bashrc
    # 执行.bashrc
    source .bashrc

    重新运行helm ls:

    运行helm ls命令

    可以看到helm已经能于Kubernetes集群正常交互。

  3. 安装WordPress

    # 添加远程helm chart仓库
    helm repo add bitnami https://charts.bitnami.com/bitnami
    
    # 安装WordPress
    helm install wordpress bitnami/wordpress \
    --namespace=wordpress \
    --set wordpressUsername=your-name,wordpressPassword=your-pwd,mariadb.mariadbRootPassword=db-root-pwd,ingress.enabled=true,ingress.hostname=your.domain.name

    参数含义及更多参数请参考:bitnami/wordpress

    通过运行helm ls -n wordpress可以看到wordpress已经部署(-n 代表namespace)。

    helm ls -n wordpress

    访问:http://your.domain.name

    wordpress网站首页效果

    能看到部署的WordPress已经成功运行,就这么简单,一两条命令的功夫就能拥有个人专属博客站点,Amazing!!!

    kubectl get

    通过kubectl get命令,可以看到Helm在底下帮我们创建好了WordPress站点所需的各种资源,Cool~

    那么Helm究竟使用了什么黑魔法呢?下面我们将通过为容器化后的应用创建Helm Chart来揭开其神秘面纱~

为应用程序定制Helm Chart

上面WordPress安装实质上是通过Helm安装远程仓库经过打包后的Chart实现的,在实际业务场景中我们也可以为应用程序定制Helm Chart。

  1. 创建Helm Chart

    helm create 

    helm create your-helm-chart-name

    可以看到我们已经成功创建了Helm Chart:

    新创建的Helm Chart目录结构

  2. 定制Helm Chart

    上面我们已经生成了一个通用的Chart,对于一些更为复杂的应用我们也可以在此基础上进行定制。基本操作流程就是根据需求修改或新增templates目录下各Kubernetes资源声明模板文件,在其中通过golang template语法注入values.yaml文件中定义的值,具有非常大的灵活性。

    举个栗子🌰,对于一般的API应用我们常常需要部署Job或者CronJob以执行特定任务,而且需要将kubernetes Secretskubernetes ConfigMaps注入到应用程序容器的环境变量中。

    除了工作负载Deployment的模板以外,我们还需要创建Job或者CronJob相关的模板(当然你也可以将三者放到一个模板中,再通过values.yaml中传入条件去生成对应的Kubernetes声明文件)。

    创建需要的工作负载模板

    我们发现上述多个工作负载模板中会有很多重复的内容(如容器的环境变量定义),对于这些可复用的模块我们可以将其在_helpers.tpl文件中新增声明:

    {{/* Add following code to templates/_helpers.tpl */}}
    {{/*
    Create container env to use
    */}}
    {{- define ".helm.container.env" -}}
    env:
     - name: MONGODB_HOST
       valueFrom:
         configMapKeyRef:
           name: your-api
           key: mongodb_host
     - name: MONGODB_DATABASE
       valueFrom:
         configMapKeyRef:
           name: your-api
           key: mongodb_database
     - name: MONGODB_USERNAME
       valueFrom:
         secretKeyRef:
           name: your-api
           key: mongodb_username
     - name: MONGODB_PASSWORD
       valueFrom:
         secretKeyRef:
           name: your-api
           key: mongodb_password
     - name: NODE_ENV
       value: {{ default "production" .Values.nodeEnv }}
    {{- end -}}

    templates/deployment.yaml模板文件:

    {{- if eq .Values.workload.kind "Deployment" }}
    apiVersion: apps/v1
    kind: Deployment
    metadata:
     name: {{ include ".helm.fullname" . }}
     labels:
       {{- include ".helm.labels" . | nindent 4 }}
    spec:
     replicas: {{ .Values.replicaCount }}
     strategy:
       type: {{ .Values.updateStrategy.type }}
       rollingUpdate:
         maxSurge: {{ .Values.updateStrategy.rollingUpdate.maxSurge }}
         maxUnavailable: {{ .Values.updateStrategy.rollingUpdate.maxUnavailable }}
     selector:
       matchLabels:
         {{- include ".helm.selectorLabels" . | nindent 6 }}
     template:
       metadata:
         labels:
           {{- include ".helm.selectorLabels" . | nindent 8 }}
       spec:
       {{- with .Values.imagePullSecrets }}
         imagePullSecrets:
           {{- toYaml . | nindent 8 }}
       {{- end }}
         serviceAccountName: {{ include ".helm.serviceAccountName" . }}
         securityContext:
           {{- toYaml .Values.podSecurityContext | nindent 8 }}
         containers:
           - name: {{ .Chart.Name }}
             securityContext:
               {{- toYaml .Values.securityContext | nindent 12 }}
             image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
             imagePullPolicy: {{ .Values.image.pullPolicy }}
             {{- include ".helm.container.env" . | nindent 10 }}
             {{- if .Values.workload.isGeneralDeployment }}
             ports:
               - name: http
                 containerPort: 3000
                 protocol: TCP
             livenessProbe:
               httpGet:
                 path: /api-health-check-endpoint
                 port: http
             readinessProbe:
               httpGet:
                 path: /api-health-check-endpoint
                 port: http
             {{- else }}
             command: {{ .Values.workload.command }}
             args: {{ .Values.workload.args }}
             {{- end }}
             resources:
               {{- toYaml .Values.resources | nindent 12 }}
         {{- with .Values.nodeSelector }}
         nodeSelector:
           {{- toYaml . | nindent 8 }}
         {{- end }}
       {{- with .Values.affinity }}
         affinity:
           {{- toYaml . | nindent 8 }}
       {{- end }}
       {{- with .Values.tolerations }}
         tolerations:
           {{- toYaml . | nindent 8 }}
       {{- end }}
    {{- end }}
    

    templates/cronJob.yaml文件

    {{- if eq .Values.workload.kind "CronJob" }}
    apiVersion: batch/v1beta1
    kind: CronJob
    metadata:
     name: {{ include ".helm.fullname" . }}
     labels:
       {{- include ".helm.labels" . | nindent 4 }}
    spec:
     schedule: {{ .Values.workload.schedule }}
     jobTemplate:
       spec:
         template:
           metadata:
             labels:
               {{- include ".helm.selectorLabels" . | nindent 12 }}
           spec:
           {{- with .Values.imagePullSecrets }}
             imagePullSecrets:
               {{- toYaml . | nindent 12 }}
           {{- end }}
             serviceAccountName: {{ include ".helm.serviceAccountName" . }}
             securityContext:
               {{- toYaml .Values.podSecurityContext | nindent 12 }}
             containers:
               - name: {{ .Chart.Name }}
                 image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
                 imagePullPolicy: {{ .Values.image.pullPolicy }}
                 {{- include ".helm.container.env" . | nindent 14 }}
                 command: {{ .Values.workload.command }}
                 args: {{ range .Values.workload.args }}
                   - {{ . }}
                 {{ end }}
             restartPolicy: OnFailure
    {{- end }}
    

    templates/job.yaml文件

    {{- if eq .Values.workload.kind "Job" }}
    apiVersion: batch/v1
    kind: Job
    metadata:
     name: {{ include ".helm.fullname" . }}
     labels:
       {{- include ".helm.labels" . | nindent 4 }}
    spec:
     template:
       metadata:
         labels:
           {{- include ".helm.selectorLabels" . | nindent 8 }}
       spec:
       {{- with .Values.imagePullSecrets }}
         imagePullSecrets:
           {{- toYaml . | nindent 8 }}
       {{- end }}
         serviceAccountName: {{ include ".helm.serviceAccountName" . }}
         securityContext:
           {{- toYaml .Values.podSecurityContext | nindent 8 }}
         containers:
           - name: {{ .Chart.Name }}
             image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
             imagePullPolicy: {{ .Values.image.pullPolicy }}
             {{- include ".helm.container.env" . | nindent 10 }}
             command: {{ .Values.workload.command }}
             args: {{ range .Values.workload.args }}
               - {{ . }}
             {{ end }}
         restartPolicy: Never
    {{- end }}
    

    templates/service.yaml文件:

    {{- if and .Values.workload.isGeneralDeployment (eq .Values.workload.kind "Deployment") -}}
    apiVersion: v1
    kind: Service
    metadata:
     name: {{ include ".helm.fullname" . }}
     labels:
       {{- include ".helm.labels" . | nindent 4 }}
    spec:
     type: {{ .Values.service.type }}
     ports:
       - port: {{ .Values.service.port }}
         targetPort: http
         protocol: TCP
         name: http
     selector:
       {{- include ".helm.selectorLabels" . | nindent 4 }}
    {{- end -}}
    

    在使用支持外部负载均衡器的云提供商的服务时,设置 type 的值为 LoadBalancer, 将为 Service 提供负载均衡器。由于我们使用自建microk8s集群,LoadBalancer并没有效果,所以需要设置ingress以将Service暴露至公网。

    templates/ingress.yaml文件:

    {{- if .Values.ingress.enabled -}}
    {{- $fullName := include ".helm.fullname" . -}}
    {{- $svcPort := .Values.service.port -}}
    {{- if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
    apiVersion: networking.k8s.io/v1beta1
    {{- else -}}
    apiVersion: extensions/v1beta1
    {{- end }}
    kind: Ingress
    metadata:
     name: {{ $fullName }}
     labels:
       {{- include ".helm.labels" . | nindent 4 }}
     {{- with .Values.ingress.annotations }}
     annotations:
       {{- toYaml . | nindent 4 }}
     {{- end }}
    spec:
    {{- if .Values.ingress.tls }}
     tls:
     {{- range .Values.ingress.tls }}
       - hosts:
         {{- range .hosts }}
           - {{ . | quote }}
         {{- end }}
         secretName: {{ .secretName }}
     {{- end }}
    {{- end }}
     rules:
     {{- range .Values.ingress.hosts }}
       - host: {{ .host | quote }}
         http:
           paths:
           {{- range .paths }}
             - path: {{ . }}
               backend:
                 serviceName: {{ $fullName }}
                 servicePort: {{ $svcPort }}
           {{- end }}
     {{- end }}
    {{- end }}
    

    通过values.yaml定义默认值,实际部署时可传参替换。

    # Default values for .helm.
    # This is a YAML-formatted file.
    # Declare variables to be passed into your templates.
    replicaCount: 1
    
    updateStrategy:
     type: RollingUpdate
     rollingUpdate:
       maxSurge: 1
       maxUnavailable: 1
    
    nodeEnv: production
    
    image:
     repository: your.image.repository/path
     tag: latest
     pullPolicy: IfNotPresent
    
    imagePullSecrets: [{ name: your-image-registry }]
    nameOverride: "your-app-name"
    fullnameOverride: "your-app-name"
    
    workload:
     # Support Deployment,Job,CronJob
     kind: Deployment
     isGeneralDeployment: true
     command: ["yarn"]
     args: ["start"]
    
    serviceAccount:
     # Specifies whether a service account should be created
     create: false
     annotations: {}
    
    podSecurityContext: {}
    
    securityContext: {}
    
    service:
     type: ClusterIP
     port: 80
    
    ingress:
     enabled: true
     annotations:
       kubernetes.io/ingress.class: nginx
       kubernetes.io/tls-acme: "true"
     hosts:
       - host: your.domain.name
         paths:
           - '/'
     tls: []
    
    resources: {}
    
    nodeSelector: {}
    
    tolerations: []
    
    affinity: {}
    
  3. 启用Ingress控制器

    为了让 Ingress 资源工作,集群必须有一个正在运行的 Ingress 控制器Ingress控制器有很多种,我们将使用Kubernetes官方维护的nginx ingress控制器,在microk8s中启用ingress控制器非常简单:

    microk8s.enable ingress  # 将启用nginx ingress控制器
  4. 部署定制的Helm Chart

    helm upgrade -f values.yaml --install --namespace=default --set nodeEnv=staging,workload.kind=Job,workload.args={start-your-job,additional-args},replicaCount=2 your-app-name .

    其中:

    • -f : 默认value文件。
    • --namespace : 部署到某命名空间(对应命名空间需要在Kubernetes集群中存在)
    • --install : 若命名为your-app-name的helm应用不存在则安装,否则更新。
    • --set : 替换默认value的值。
    • your-app-name : helm应用名称(方便管理)
    • . : Helm Chart 所在根目录

    PS: 后续若有需要可以将定制的Helm Chart进行打包,并推送到指定远程仓库。

小结

Helm大大减少了向Kubernetes集群部署应用程序的复杂性,让我们能够快速的部署应用程序,同时也可通过远程的Chart仓库直接安装所需应用。编写定制的Chart也比较直接,需要对Kubernetes资源对象以及golang template有所了解。通过对Helm的应用,能更好地提高开发者的工作效率,以将更多精力聚焦在业务代码的开发上。

云原生时代,拥抱微服务(二)—— 容器化应用

前文我们已经搭建好k8s集群,接下来就可以对部分项目进行容器化改造了。

“万丈高楼平地起,敲好代码是关键。” —— 皮皮叨叨

创建镜像文件Dockerfile

以Node.js应用为例:

FROM node:12-alpine

# install tzdata so that we can set timezone which default is UTC
RUN apk add tzdata
ENV TZ Asia/Shanghai

# set our node environment, either development or production
# defaults to production, can override this to development on build and run
ARG NODE_ENV=production
ENV NODE_ENV $NODE_ENV

# default to port 3000 for node, and 9229 and 9230 (tests) for debug
ARG PORT=3000
ENV PORT $PORT
EXPOSE $PORT 9229 9230

# install dependencies first, in a different location for easier app bind mounting for local development
# due to default /opt permissions we have to create the dir with root and change perms
RUN mkdir /your-app && chown node:node /your-app
RUN mkdir /var/log/your-app && chown node:node /var/log/your-app
WORKDIR /your-app
# the official node image provides an unprivileged user as a security best practice
# but we have to manually enable it. We put it here so yarn installs dependencies as the same
# user who runs the app.
# https://github.com/nodejs/docker-node/blob/master/docs/BestPractices.md#non-root-user
USER node
COPY --chown=node:node . .
RUN yarn install --production=true
ENV PATH /your-app/node_modules/.bin:$PATH

CMD [ "yarn", "start" ]

PS: .dockerignore文件可通过gitignore.io生成。

应用配置信息及密钥管理

普通配置信息

ConfigMaps是一种Kubernetes资源,允许你为容器保存配置信息,这些配置后续可以通过挂载为容器的环境变量或者文件进行访问。

# your-config.yaml
---
apiVersion: v1
kind: ConfigMap
metadata:
  name: your-api
data:
  mongodb_host: your.mongodb.host
  mongodb_database: your-database-name

通过在k8s集群中使用kubectl apply命令创建上述命名为your-apiConfigMap资源(如果该资源已经存在,则进行更新动作)。

kubectl apply -f path-to/your-config

密钥等敏感信息

不像ConfigMapsSecrets是一种用于存放敏感信息的Kubernetes资源,一般可用于存放各类凭证、ssh密钥,TLS密钥及证书,或者其他在应用中需要使用的敏感数据。Secrets同样可以挂载为容器的环境变量或者文件。

Secrets被保存为base64编码的字符串,我们需要在Secrets声明文件中将对应敏感信息(如数据库用户名与密码)转换为base64格式。可使用如下命令:

echo -n "db-user-name" | base64
# output: ZGItdXNlci1uYW1l

echo -n "db-pwd" | base64
# output: ZGItcHdk

注意,上述命令输出后的base64编码字符串就是我们需要保持到Kubernetes中的Secrets信息。

# your-secrets.yaml
---
apiVersion: v1
kind: Secret
metadata:
  name: your-api
data:
  mongodb_username: ZGItdXNlci1uYW1l
  mongodb_password: ZGItcHdk

使用kubectl apply命令创建对应Secrets资源:

kubectl apply -f path-to/your-secrets.yaml

【温馨提示】base64编码的字符串并不是被加密的,需要将上述Secrets声明文件保存到足够安全的地方,并且防止泄露!!!建议在Kubernetes集群中统一进行管理。

为应用程序创建Deployment工作负载

工作负载(Workloads)资源可以理解为在Kubernetes中的应用程序,主要包括:DeploymentStatefulSetDaemonSetJobCronJob等类型。

Deployment主要用于扩展与更新无状态容器Pod,适用于我们无状态的应用程序。Deployment会自动创建副本集(ReplicaSet)资源,以用于替换失败的Pods,弹性扩展或减少Pods

# your-deployment.yaml
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: your-api
  labels:
    app: your-api-label
spec:
  replicas: 3
  selector:
    matchLabels:
      app: your-api-label
  template:
    metadata:
      labels:
        app: your-api-label
    spec:
      containers:
        - name: your-api
          image: your-api-image:1.0.0
          env:
            - name: MONGODB_HOST
              valueFrom:
                configMapKeyRef:
                  name: your-api
                  key: mongodb_host
            - name: MONGODB_DATABASE
              valueFrom:
                configMapKeyRef:
                  name: your-api
                  key: mongodb_database
            - name: MONGODB_USER
              valueFrom:
                secretKeyRef:
                  name: your-api
                  key: mongodb_username
            - name: MONGODB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: your-api
                  key: mongodb_password

使用kubectl apply命令创建对应Deployment资源:

kubectl apply -f path-to/your-depoyment.yaml

使用Service公开Deployment工作负载

我们还需要通过创建Service资源以更好地访问不同Pod中的应用程序,并且能做到负载均衡。这主要是由于Pod是短暂的,一旦由于某种原因被销毁,其对应的IP地址也会被吊销,然后新生的Pod将获得一个不同的IP地址。

# your-service.yaml
---
apiVersion: v1
kind: Service
metadata:
  name: your-api
spec:
  type: ClusterIP
  selector:
    app: your-api-label
  ports:
    - protocol: TCP
      port: 80
      targetPort: http

使用kubectl apply命令创建对应Service资源:

kubectl apply -f path-to/your-service.yaml

总结

我们首先通过创建Dockerfile以用于生成应用程序的容器镜像,然后通过使用ConfigMapsSecrets资源管理应用程序的配置及敏感信息,最后再运用DeploymentService资源部署及公开应用程序。

微服务高楼的基石貌似已经搭好了,胜利的曙光就在眼前。但是机智且追求效率的你一定发现了:一个应用程序这样做还好,可如果有多个应用程序也需要容器化,都分别去声明各种Kubernetes资源岂不是很繁琐?你这个ClusterIP类型的Service似乎还无法从外网访问?

云原生时代,拥抱微服务(一) —— 搭建k8s集群

背景

现有系统是以传统的单片形式进行架构,通过采用Nginx对后端项目及前端进行反向代理。基础设施使用的是阿里云ECS服务器,直接在ECS中运行项目。在项目初期这种方式可以较快的搭建起应用,但随着项目不断地迭代,整个后端系统变得越来越臃肿,且难以进行弹性扩展和维护。为了让日后的开发及运维更为平顺,微服务化势在必行。

时间就是生命,提升效率就是延长生命。 —— 皮皮叨叨

既然有了这个想法,下面就要一步步落实了。微服务化第一步就是要将项目进行容器化,而为了更好地管理容器就需要一套能“用于自动部署、扩展和管理容器化应用程序的平台”,我们将采用业界普遍使用的Kubernetes作为容器管理平台。

云平台K8S or 自行搭建?

我们最终选择自行搭建K8S平台,主要基于下述原因:

  • 已经购买了包年的ECS套餐
  • 基于费用成本考虑,阿里云容器服务Kubernetes版计费更为复杂(除了集群管理费,还包括云服务器、NAT网关、负载均衡等相关费用),总体而言费用相对更高。
  • 需要平稳过渡到新的部署架构,先从后端项目入手,成熟后再将前端等其他项目迁移过去。
  • 如果后续有需要的话,自建K8S也可以很快的迁移到使用云平台K8S方案(通过配置文件对Kubernetes对象进行声明式管理)

我们将使用轻量的、产品级且易于运维的microk8s搭建K8S集群。

搭建microk8s集群(基于Ubuntu 18.04)

  1. 安装snappy包管理器(若已安装,请忽略)
sudo apt update
sudo apt install snapd
  1. 安装microk8s(以1.17版本为例)
sudo snap install microk8s --classic --channel=1.17/stable

## 配置防火墙以允许pod间通信
sudo ufw allow in on cni0 && sudo ufw allow out on cni0

## 创建kubectl别名以方便后续使用
echo "alias kubectl='microk8s.kubectl'" >> ~/.bashrc
source ~/.bashrc
## 或者
sudo snap alias microk8s.kubectl kubectl
  1. 开启CoreDNS以支持k8s地址解析服务(推荐启用)
microk8s.enable dns 

## 查看microk8s状态
microk8s.status

可以看到microk8s已成功运行且dns插件已启用

## 检查coreDNS运行情况
kubectl get pods --all-namespaces

相关pod处于containerCreateing状态

发现相关pod一直处于ContainerCreating状态!!!

## 继续查找原因
kubectl describe pod/coredns-9b8997588-dp9cx -n kube-system

发现由于某种原因 *无法获取* 指定镜像 **k8s.gcr.io/pause:3.1**

发现由于某种原因 无法获取 指定镜像 k8s.gcr.io/pause:3.1解决方法如下:

## 下载docker hub 上的相关镜像
sudo docker pull mirrorgooglecontainers/pause:3.1 
## 将相关镜像tag为 k8s.gcr.io/pause:3.1
sudo docker tag mirrorgooglecontainers/pause:3.1 k8s.gcr.io/pause:3.1
## 保存镜像为本地镜像文件
sudo docker save k8s.gcr.io/pause > pause.tar
## ctr导入本地镜像
sudo microk8s.ctr image import pause.tar
  1. 增加microk8s节点
## 主节点运行命令
microk8s add-node

上述命令打印出microk8s join指令,用于在其他需要加入集群的节点执行。PS:需要确保节点间网络互通(并在安全组及主机防火墙开放),否则加入不成功!!!

## 查看k8s集群节点信息
kubectl get nodes


大功告成!!!接下来可以愉快地对项目进行容器化管理了^-^

Node.js日志优化之使用RequestId关联日志

背景


现状

现有系统采用pino用于普通日志输出,采用pino-http用于输出服务器请求日志。

./src/createServer.js文件

基于Express创建Server。为了直观简洁,以下仅展示与log相关代码。

'use strict';

const express = require('express');
const logger = require('./utils/logger');
const requestLogger = require('./middlewares/requestLogger');
const errorHandler = require('./middlewares/errorHandler');

const createServer = async () => {
  logger.info('Starting to createServer...');
  const app = express();
  // skip other middlewares...
  app.use(requestLogger)
  // skip... routers
  app.use(errorHandler);
  return app;
};

module.exports = createServer;

./src/utils/logger.js

封装pino日志组件,所有日志输出均调用该组件。

'use strict';

const pino = require('pino');

const logger = pino()

module.exports = logger;

./src/middlewares/requestLogger.js*

封装服务器请求日志中间件pino-http,用于打印请求日志。

'use strict';

const pinoHttp = require('pino-http');
const uuidv4 = require('uuid/v4');
const logger = require('../utils/logger.js');

const requestLogger = pinoHttp({
  logger,
  // Have default ID which is increasing number per process, better use UUID
  // to avoid collection in server cluster
  genReqId: () => uuidv4(),
});

module.exports = requestLogger;

./src/middlewares/errorHandlers.js*

封装错误处理中间件,用于在最后统一处理请求错误。

'use strict';

module.exports = (err, req, res, next) => {
  req.log.error({
    context: 'errorHandler middleware',
    query: req.query,
    body: req.body,
    stack: err.stack.split('\n'),
  });
  const {
    errorCode,
    statusCode,
    name,
    message,
  } = err;
  res
    .status(statusCode)
    .json({
      errors: [
        {
          errorCode,
          name,
          message,
        },
      ],
    });
};

存在的问题

以上代码能满足基本的日志需求,直到有一天。

程序猿小程抱怨道:“哇,好蛋疼~,上周末ON CALL有问题让我DEBUG,看着成堆的日志查找了半天才发现是评论团队那边的XXXAPI出了问题。”

程序猿小序:“em,所有请求都要经过我们的API Gateway,同一时间的日志确实很多,我也感觉每次DEBUG挺耗时间的,而且每次有问题都会先找我们查找原因🤣。”

程序猿大猿:“我们的错误处理中间件会打印出该请求的错误信息,但有些中间过程产生的错误(如请求其他team的API)并不需要抛出到错误处理中间件处理,这些日志信息的确不太好找,以及在Services或者Drivers里面的日志。我们最好能将每个请求相关的所有日志都串起来,这样不就好排查问题了吗。”

运维小运:“Kibana对日志的搜索功能还是很强大的,而且Amazon CloudFront会给每个请求头添加唯一的ID,你们倒是可以通过对字段x-amz-cf-id进行搜索🤔。”

程序猿大猿:“嗯,那小程你负责去跟进下这个日志的优化问题。”

小程欣然地答应了。

使用RequestId关联日志


思考

“RequestId在request这个对象里面,要怎么使用RequestId关联现有的日志呢?”,小程思考了片刻,最先想到的方法是直接通过函数传值,于是他兴奋地开了个branch尝试去做。

改了几个文件后。。。

“不对啊,这样的方式也太蠢了吧!要在ControllersServicesDriversUtils......,几乎每个函数都加上requestId这个参数,而且这样写出来的代码即难看又冗余。但是不这么做,要如何做到呢?”,小程自言自语到。

小序不小心听到了小程的疑虑,也思考了片刻,“em~看来有点棘手啊,貌似只能从上下文(Context)这个角度入手”。

“嗯,对logger进行进一步的封装,使其拥有不断回溯上下文的能力,这样就可以溯源到request这个对象,进而获取RequestId打印出来。但具体要如何做呢?”,小程回答道。

“这种问题算是比较常见,坊间应该也有对应的方案,何不Google一下?”,小程继续补充到。

通过查阅相关资料,可以通过使用cls-rtracer,或者pino社区推荐的cls-proxify来实现。本文将使用cls-proxify实现。

实现

有了具体的实现思路后,小程兴奋的捣鼓起来。。。

./src/constants/log.js

新增常量便于后期维护

'use strict';

module.exports = {
  CLS_KEY: 'clsKeyLogger',
  REQUEST_ID: 'X-Request-Id',
};

./src/middlewares/reqeustId.js

requestId中间件,确保RequestId全局统一性。

'use strict';

const uuidv4 = require('uuid/v4');

const CF_HEADER_NAME = 'X-Amz-Cf-Id';
const { REQUEST_ID } = require('../constants/log');

module.exports = (req, res, next) => {
  req.id = req.headers[CF_HEADER_NAME.toLowerCase()] || uuidv4();

  res.setHeader(REQUEST_ID, req.id);
  next();
};

./src/createServer.js

使用requestId中间件

'use strict';

const express = require('express');
const logger = require('./utils/logger');
const requestLogger = require('./middlewares/requestLogger');
const errorHandler = require('./middlewares/errorHandler');
const requestId = require('./middlewares/requestId');

const createServer = async () => {
  logger.info('Starting to createServer...');
  const app = express();
  // skip other middlewares...
  app.use(requestId); // use requestId middleware
  app.use(requestLogger)
  // skip... routers
  app.use(errorHandler);
  return app;
};

module.exports = createServer;

./src/middlewares/requestLogger

requestLogger的生成的ID使用RequestId中间件生成的,确保统一。

'use strict';

const pinoHttp = require('pino-http');
const logger = require('../utils/logger.js');

const requestLogger = pinoHttp({
  logger,
  // Have default ID which is increasing number per process, better use UUID
  // to avoid collection in server cluster
  genReqId: (req) => req.id, // use req.id which generate from requestId middleware
});

module.exports = requestLogger;

应用cls-proxify,使得每个request都有相应的child logger。记得安装依赖:npm i cls-proxify cls-hooked

./src/createServer.js

'use strict';

const express = require('express');
const { clsProxifyExpressMiddleware } = require('cls-proxify/integration/express');
const { defaultLogger } = require('./utils/logger');
const requestLogger = require('./middlewares/requestLogger');
const errorHandler = require('./middlewares/errorHandler');
const requestId = require('./middlewares/requestId');
const { CLS_KEY, REQUEST_ID } = require('./constants/log');

const createServer = async () => {
  defaultLogger.info('Starting to createServer...');
  const app = express();
  // skip other middlewares...
  app.use(requestId); // use requestId middleware
  app.use(requestLogger);

  // Integrate pino with CLS.For creating dynamically configured child loggers for each request.
  // NOTE: Put any third party middleware that does not need access to requestId before it.
  // Details see: https://github.com/keenondrums/cls-proxify
  app.use(
    clsProxifyExpressMiddleware(CLS_KEY, req => defaultLogger.child({ [REQUEST_ID]: req.id })),
  );

  // skip... routers
  app.use(errorHandler);
  return app;
};

module.exports = createServer;

./src/utils/logger.js

'use strict';

const pino = require('pino');
const { clsProxify } = require('cls-proxify');
const { CLS_KEY } = require('../constants/log');

const defaultLogger = pino(config.get('logger'));
const logger = clsProxify(CLS_KEY, defaultLogger);

module.exports = {
  defaultLogger, // default logger, won't print RequestId
  logger, // logger with CLS feature, will print RequestId if has.
};

大功告成。。。

本地测试通过后,小程兴奋的提了个PR,并注明:

此PR的一些参考资料:

https://itnext.io/request-id-tracing-in-node-js-applications-c517c7dab62d https://nodejs.org/api/async_hooks.html pinojs/pino#601 https://github.com/keenondrums/cls-proxify#live-demos

大猿review了这个PR,Approve并评论:

Nice Job! 基于我们这个Api gateway的特点,好的日志是很有必要的。 至于性能方面,我们有做Cache,并且Devops在Amozon ClouldFront也做了CDN CACHE,所以影响暂时不会很大。

小程merge这个branch,等CI部署成功后,到Kibana上看了看日志,嘴角微微上扬,喝起了82年的卡布奇洛。

kibana

实现到此已告一段落,对原理感兴趣的读者可以继续往下阅读。以下仅供参考,如有错误的地方,欢迎指正。

追根溯源

纸上得来终觉浅,小程不满足于仅仅停留在用完即弃的层面,想要进一步探究其来龙去脉。

在许多其他语言及平台(如Java Servlet container),HTTP服务器是基于多线程的架构及阻塞I/O,这使得它们天然就可以将特定请求所在的线程标识用于跟踪该请求的日志信息。另外,也可以通过使用Thread-local-storage(TLS)以键值对的方式存储/获取当前线程关联的上下文。在我们这个例子中,可以用TLS存储每个请求的ReqeustId或者其他诊断信息。很多的日志框架会默认内置TLS这个特性。

那为什么在Node.js的世界不可以这样做呢?小程思考到。

由于Node.js基于事件循环的异步特性,TLS自然派不上用场,毕竟JS代码是在一个主线程处理的。

“虽然Node.js没有类似TLS的API,但我们也会有类似的需求,该怎么办呢?似乎只能从Context Object(上下文对象)这块入手。”,小程继续思索到。

其实Node.js社区很早前就注意到了这个问题,最终提出了continuation-local-storage(CLS)的概念。CLS就像线程编程中的TLS,但其基于Node.js的回调链而不是线程。起初CLS基于使用process.addAsyncListener API,直到Node.js v0.12该API不可用,之后是通过使用该API的polyfill async-listener来实现。由于这个polyfill做了很多封装内置Node API的猴子补丁,所以不太推荐使用。

幸运的是,CLS库衍生出了后续版本cls-hooked,使用了Node.js v8.2.1后内置的API async_hooks。尽管async_hooks仍然处于试验阶段,但其仍然比使用猴子补丁CLS要好些。

cls-hooked是挺好的,能解决回溯context的问题,但是我还是需要做大量的工作封装并将其集成进express以及pino中,不知有没有以及做好这些工作的库?”。小程百思不得其解,直到找到pino社区开发的cls-proxify

cls-proxify基于cls-hooked并使用了代理模式,可以为每个请求创建一个包含动态上下文的child logger,原理如下图:

how cls-proxify works

“探索知识的过程真的是非常有趣呢!”,小程心想,愉快地将剩下的卡布奇诺一饮而尽。