Skip to content

Node.js

从本章开始,我们就正式开启JavaScript的后端开发之旅。

Node.js是目前非常火热的技术,但是它的诞生经历却很奇特。

众所周知,在Netscape设计出JavaScript后的短短几个月,JavaScript事实上已经是前端开发的唯一标准。

后来,微软通过IE击败了Netscape后一统桌面,结果几年时间,浏览器毫无进步。

没有竞争就没有发展。微软认为IE6浏览器已经非常完善,几乎没有可改进之处,然后解散了IE6开发团队!而Google却认为支持现代Web应用的新一代浏览器才刚刚起步,尤其是浏览器负责运行JavaScript的引擎性能还可提升10倍。

先是Mozilla借助已壮烈牺牲的Netscape遗产在2002年推出了Firefox浏览器,紧接着Apple于2003年在开源的KHTML浏览器的基础上推出了WebKit内核的Safari浏览器,不过仅限于Mac平台。

随后,Google也开始创建自家的浏览器。他们也看中了WebKit内核,于是基于WebKit内核推出了Chrome浏览器。

Chrome浏览器是跨Windows和Mac平台的,并且,Google认为要运行现代Web应用,浏览器必须有一个性能非常强劲的JavaScript引擎,于是Google自己开发了一个高性能JavaScript引擎,名字叫V8,以BSD许可证开源。

现代浏览器大战让微软的IE浏览器远远地落后了,因为他们解散了最有经验、战斗力最强的浏览器团队!回过头再追赶却发现,支持HTML5的WebKit已经成为手机端的标准了,IE浏览器从此与主流移动端设备绝缘。

浏览器大战和Node有何关系?

话说有个叫Ryan Dahl的歪果仁,他的工作是用C/C++写高性能Web服务。对于高性能,异步IO、事件驱动是基本原则,但是用C/C++写就太痛苦了。于是这位仁兄开始设想用高级语言开发Web服务。他评估了很多种高级语言,发现很多语言虽然同时提供了同步IO和异步IO,但是开发人员一旦用了同步IO,他们就再也懒得写异步IO了,所以,最终,Ryan瞄向了JavaScript。

因为JavaScript是单线程执行,根本不能进行同步IO操作,所以,JavaScript的这一“缺陷”导致了它只能使用异步IO。

选定了开发语言,还要有运行时引擎。这位仁兄曾考虑过自己写一个,不过明智地放弃了,因为V8就是开源的JavaScript引擎。让Google投资去优化V8,咱只负责改造一下拿来用,还不用付钱,这个买卖很划算。

于是在2009年,Ryan正式推出了基于JavaScript语言和V8引擎的开源Web服务器项目,命名为Node.js。虽然名字很土,但是,Node第一次把JavaScript带入到后端服务器开发,加上世界上已经有无数的JavaScript开发人员,所以Node一下子就火了起来。

在Node上运行的JavaScript相比其他后端开发语言有何优势?

最大的优势是借助JavaScript天生的事件驱动机制加V8高性能引擎,使编写高性能Web服务轻而易举。

其次,JavaScript语言本身是完善的函数式语言,在前端开发时,开发人员往往写得比较随意,让人感觉JavaScript就是个“玩具语言”。但是,在Node环境下,通过模块化的JavaScript代码,加上函数式编程,并且无需考虑浏览器兼容性问题,直接使用最新的ECMAScript 6标准,可以完全满足工程上的需求。

安装Node.js

由于Node.js平台是在后端运行JavaScript代码,所以,必须首先在本机安装Node环境。

安装Node.js

目前Node.js的最新版本是22.3.0,LTS版本是20.14.0,追求稳定性如服务器上长期运行的Node环境可以选择LTS版本,本地开发和测试可以选择最新版本。

要安装Node.js,首先,从Node.js官网下载对应平台的安装程序,初学者建议选择Prebuilt Installer,选择版本,再选操作系统,最后选CPU类型后点Download下载:

download

有经验的开发者可以选择Package Manager,它允许本地安装多个不同版本的Node并切换至不同版本。

在Windows上安装时务必选择全部组件,包括勾选Add to Path

安装完成后,在Windows环境下,请打开命令提示符,然后输入node -v,如果安装正常,你应该看到v22.3.0这样的输出:

plain
C:\Users\IEUser> node -v
v22.3.0

继续在命令提示符输入node,此刻你将进入Node.js的交互环境。在交互环境下,你可以输入任意JavaScript语句,例如100+200,回车后将得到输出结果。

要退出Node.js环境,连按两次Ctrl+C。

npm

在正式开始Node.js学习之前,我们先认识一下npm。

npm是什么东东?npm其实是Node.js的包管理工具(package manager)。

为啥我们需要一个包管理工具呢?因为我们在Node.js上开发时,会用到很多别人写的JavaScript代码。如果我们要使用别人写的某个包,每次都根据名称搜索一下官方网站,下载代码,解压,再使用,非常繁琐。于是一个集中管理的工具应运而生:大家都把自己开发的模块打包后放到npm官网上,如果要使用,直接通过npm安装就可以直接用,不用管代码存在哪,应该从哪下载。

更重要的是,如果我们要使用模块A,而模块A又依赖于模块B,模块B又依赖于模块X和模块Y,npm可以根据依赖关系,把所有依赖的包都下载下来并管理起来。否则,靠我们自己手动管理,肯定既麻烦又容易出错。

讲了这么多,npm究竟在哪?

其实npm已经在Node.js安装的时候顺带装好了。我们在命令提示符或者终端输入npm -v,应该看到类似的输出:

text
C:\>npm -v
10.8.0

如果直接输入npm,你会看到类似下面的输出:

text
C:\> npm

Usage: npm <command>

where <command> is one of:
    ...

上面的一大堆文字告诉你,npm需要跟上命令。现在我们不用关心这些命令,后面会一一讲到。目前,你只需要确保npm正确安装了,能运行就行。

小结

请在本机安装Node.js环境,并确保nodenpm能正常运行。

第一个Node程序

在前面的所有章节中,我们编写的JavaScript代码都是在浏览器中运行的,因此,我们可以直接在浏览器中敲代码,然后直接运行。

从本章开始,我们编写的JavaScript代码将不能在浏览器环境中执行了,而是在Node环境中执行,因此,JavaScript代码将直接在你的计算机上以命令行的方式运行,所以,我们要先选择一个文本编辑器来编写JavaScript代码,并且把它保存到本地硬盘的某个目录,才能够执行。

那么问题来了:文本编辑器到底哪家强?

首先,请注意,绝对不能用Word和写字板。Word和写字板保存的不是纯文本文件。如果我们要用记事本来编写JavaScript代码,要务必注意,记事本以UTF-8格式保存文件时,会自作聪明地在文件开始的地方加上几个特殊字符(UTF-8 BOM),结果经常会导致程序运行出现莫名其妙的错误。

所以,用记事本写代码时请注意,保存文件时使用ANSI编码,并且暂时不要输入中文。

如果你的电脑上已经安装了Visual Studio Code,也可以用来编写JavaScript代码,注意用UTF-8格式保存。

输入以下代码:

javascript
'use strict';
console.log('Hello, world.');

第一行总是写上'use strict';是因为我们总是以严格模式运行JavaScript代码,避免各种潜在陷阱。

然后,选择一个目录,例如C:\Workspace,把文件保存为hello.js,就可以打开命令行窗口,把当前目录切换到hello.js所在目录,然后输入以下命令运行这个程序了:

plain
C:\Workspace> node hello.js
Hello, world.

也可以保存为别的名字,比如first.js,但是必须要以.js结尾。此外,文件名只能是英文字母、数字和下划线的组合。

如果当前目录下没有hello.js这个文件,运行node hello.js就会报错:

text
C:\Workspace> node hello.js
node:internal/modules/cjs/loader:1227
    throw err;
          ^
Error: Cannot find module 'C:\Workspace\hello.js'
    at Module._resolveFilename
    ...

报错的意思就是,没有找到hello.js这个文件,因为文件不存在。这个时候,就要检查一下当前目录下是否有这个文件了。

命令行模式和Node交互模式

请注意区分命令行模式和Node交互模式。

看到类似C:\>是在Windows提供的命令行模式:

┌────────────────────────────────────────────────────────┐
│Command Prompt                                    - □ x │
├────────────────────────────────────────────────────────┤
│Microsoft Windows [Version 10.0.0]                      │
│(c) 2015 Microsoft Corporation. All rights reserved.    │
│                                                        │
│C:\> node hello.js                                      │
│Hello, world.                                           │
│                                                        │
│C:\>                                                    │
│                                                        │
└────────────────────────────────────────────────────────┘

在命令行模式下,可以执行node进入Node交互式环境,也可以执行node hello.js运行一个.js文件。

看到>是在Node交互式环境下:

┌────────────────────────────────────────────────────────┐
│Command Prompt                                    - □ x │
├────────────────────────────────────────────────────────┤
│Microsoft Windows [Version 10.0.0]                      │
│(c) 2015 Microsoft Corporation. All rights reserved.    │
│                                                        │
│C:\> node                                               │
│Welcome to Node.js v22.x.x.                             │
│>                                                       │
│                                                        │
└────────────────────────────────────────────────────────┘

在Node交互式环境下,我们可以输入JavaScript代码并立刻执行。

此外,在命令行模式运行.js文件和在Node交互式环境下直接运行JavaScript代码有所不同。Node交互式环境会把每一行JavaScript代码的结果自动打印出来,但是,直接运行JavaScript文件却不会。

例如,在Node交互式环境下,输入:

plain
> 100 + 200 + 300;
600

直接可以看到结果600

但是,写一个calc.js的文件,内容如下:

javascript
100 + 200 + 300;

然后在命令行模式下执行:

plain
C:\Workspace> node calc.js

发现什么输出都没有。

这是正常的。想要输出结果,必须自己用console.log()打印出来。把calc.js改造一下:

javascript
console.log(100 + 200 + 300);

再执行,就可以看到结果:

plain
C:\Workspace> node calc.js
600

小结

用文本编辑器写JavaScript程序,然后保存为后缀为.js的文件,就可以用node直接运行这个程序了。

Node的交互模式和直接运行.js文件有什么区别呢?

直接输入node进入交互模式,相当于启动了Node解释器,但是等待你一行一行地输入源代码,每输入一行就执行一行。

直接运行node hello.js文件相当于启动了Node解释器,然后一次性把hello.js文件的源代码给执行了,你是没有机会以交互的方式输入源代码的。

在编写JavaScript代码的时候,完全可以一边在文本编辑器里写代码,一边开一个Node交互式命令窗口,在写代码的过程中,把部分代码粘到命令行去验证,事半功倍!前提是得有个27'的超大显示器!

参考源码

hello.js

calc.js

搭建Node开发环境

使用文本编辑器来开发Node程序,最大的缺点是效率太低,运行Node程序还需要在命令行单独敲命令。如果还需要调试程序,就更加麻烦了。

所以我们需要一个IDE集成开发环境,让我们能在一个环境里编码、运行、调试,这样就可以大大提升开发效率。

Java的集成开发环境有Eclipse,Intellij idea等,C#的集成开发环境有Visual Studio,那么问题又来了:Node.js的集成开发环境到底哪家强?

考察Node.js的集成开发环境,重点放在启动速度快,执行简单,调试方便这三点上。当然,免费使用是一个加分项。

综合考察后,我们隆重向大家推荐Node.js集成开发环境:

Visual Studio Code

Visual Studio Code由微软出品,但它不是那个大块头的Visual Studio,它是一个精简版的迷你Visual Studio,并且,Visual Studio Code可以跨!平!台!Windows、Mac和Linux通用。

安装Visual Studio Code

可以从Visual Studio Code的官方网站下载并安装最新的版本。

安装过程中,请务必钩上以下选项“将“通过Code打开”操作添加到Windows资源管理器目录上下文菜单”:

install-vsc

这将大大提升将来的操作快捷度。

运行和调试JavaScript

在VS Code中,我们可以非常方便地运行JavaScript文件。

VS Code以文件夹作为工程目录(Workspace Dir),所有的JavaScript文件都存放在该目录下。

假设我们在C:\Work\目录下创建了一个hello目录作为工程目录,并编写了一个hello.js文件,则该工程目录的结构如下:

plain
hello/        <-- workspace dir
└── hello.js  <-- JavaScript file

启动VS Code,选择菜单File - Open Folder...,选择hello目录,就可以直接编辑hello.js文件:

vscode-edit

运行JS代码

确保当前编辑器正在编辑hello.js文件,然后在VS Code中选择左侧调试按钮,直接点击Run And Debug按钮,如果弹出环境选项则选择Node,在右下侧DEBUG CONSOLE可直接看到运行结果。

vscode-run

如果右下侧面板没有显示,左上角有几个按钮可以切换视图。

调试JS代码

在VS Code中,可以通过断点调试方便进行JavaScript的开发和测试。我们先在hello.js的编辑器中打一个断点(鼠标点击行号左侧出现小红点即为断点),然后点击Run And Debug按钮,此时进入调试模式,会自动停在断点处,左侧窗口可查看变量,顶部按钮可选择单步执行或继续执行到下一个断点,也可以随时结束程序:

vscode-debug

总的来说,使用VS Code,开发和调试JavaScript代码十分方便。

参考源码

hello.js

使用模块

在计算机程序的开发过程中,随着程序代码越写越多,在一个文件里代码就会越来越长,越来越不容易维护。

为了编写可维护的代码,我们把很多函数分组,分别放到不同的文件里,这样,每个文件包含的代码就相对较少,很多编程语言都采用这种组织代码的方式。在Node环境中,一个.js文件就称之为一个模块(module)。

使用模块有什么好处?

最大的好处是大大提高了代码的可维护性。其次,编写代码不必从零开始。当一个模块编写完毕,就可以被其他地方引用。我们在编写程序的时候,也经常引用其他模块,包括Node内置的模块和来自第三方的模块。

使用模块还可以避免函数名和变量名冲突。相同名字的函数和变量完全可以分别存在不同的模块中,因此,我们自己在编写模块时,不必考虑名字会与其他模块冲突。

在上一节,我们编写了一个hello.js文件,这个hello.js文件就是一个模块,模块的名字就是文件名(去掉.js后缀),所以hello.js文件就是名为hello的模块。

我们把hello.js改造一下,创建一个函数,这样我们就可以在其他地方调用这个函数:

javascript
'use strict';

const s = 'Hello';

function greet(name) {
    console.log(s + ', ' + name + '!');
}

module.exports = greet;

函数greet()是我们在hello模块中定义的,你可能注意到最后一行是一个奇怪的赋值语句,它的意思是,把函数greet作为模块的输出暴露出去,这样其他模块就可以使用greet函数了。

问题是其他模块怎么使用hello模块的这个greet函数呢?我们再编写一个main.js文件,调用hello模块的greet函数:

javascript
'use strict';

// 引入hello模块:
const greet = require('./hello');

let s = 'Michael';

greet(s); // Hello, Michael!

注意到引入hello模块用Node提供的require函数:

javascript
const greet = require('./hello');

引入的模块作为变量保存在greet变量中,那greet变量到底是什么东西?其实变量greet就是在hello.js中我们用module.exports = greet;输出的greet函数。所以,main.js就成功地引用了hello.js模块中定义的greet()函数,接下来就可以直接使用它了。

在使用require()引入模块的时候,请注意模块的相对路径。因为main.jshello.js位于同一个目录,所以我们用了当前目录.

javascript
const greet = require('./hello'); // 不要忘了写相对目录!

如果只写模块名:

javascript
const greet = require('hello');

则Node会依次在内置模块、全局模块和当前模块下查找hello.js,你很可能会得到一个错误:

javascript
module.js
    throw err;
          ^
Error: Cannot find module 'hello'
    at Module._resolveFilename
    ...

遇到这个错误,你要检查:

  • 模块名是否写对了;
  • 模块文件是否存在;
  • 相对路径是否写对了。

CommonJS规范

这种模块加载机制被称为CommonJS规范。在这个规范下,每个.js文件都是一个模块,它们内部各自使用的变量名和函数名都互不冲突,例如,hello.jsmain.js都申明了全局变量var s = 'xxx',但互不影响。

一个模块想要对外暴露变量(函数也是变量),可以用module.exports = variable;,一个模块要引用其他模块暴露的变量,用var ref = require('module_name');就拿到了引用模块的变量。

结论

要在模块中对外输出变量,用:

javascript
module.exports = variable;

输出的变量可以是任意对象、函数、数组等等。

要引入其他模块输出的对象,用:

javascript
const foo = require('other_module');

引入的对象具体是什么,取决于引入模块输出的对象。

深入了解模块原理

如果你想详细地了解CommonJS的模块实现原理,请继续往下阅读。如果不想了解,请直接跳到最后做练习。

当我们编写JavaScript代码时,我们可以申明全局变量:

javascript
let s = 'global';

在浏览器中,大量使用全局变量可不好。如果你在a.js中使用了全局变量s,那么,在b.js中也使用全局变量s,将造成冲突,b.js中对s赋值会改变a.js的运行逻辑。

也就是说,在ESM标准之前,JavaScript语言本身并没有一种模块机制来保证不同模块可以使用相同的变量名。

那Node.js是如何实现这一点的?

其实要实现“模块”这个功能,并不需要语法层面的支持。Node.js也并不会增加任何JavaScript语法。实现“模块”功能的奥妙就在于JavaScript是一种函数式编程语言,它支持闭包。如果我们把一段JavaScript代码用一个函数包装起来,这段代码的所有“全局”变量就变成了函数内部的局部变量。

请注意我们编写的hello.js代码是这样的:

javascript
let s = 'Hello';
let name = 'world';

console.log(s + ' ' + name + '!');

Node.js加载了hello.js后,它可以把代码包装一下,变成这样执行:

javascript
(function () {
    // 读取的hello.js代码:
    let s = 'Hello';
    let name = 'world';

    console.log(s + ' ' + name + '!');
    // hello.js代码结束
})();

这样一来,原来的全局变量s现在变成了匿名函数内部的局部变量。如果Node.js继续加载其他模块,这些模块中定义的“全局”变量s也互不干扰。

所以,Node利用JavaScript的函数式编程的特性,轻而易举地实现了模块的隔离。

但是,模块的输出module.exports怎么实现?

这个也很容易实现,Node可以先准备一个对象module

javascript
// 准备module对象:
let module = {
    id: 'hello',
    exports: {}
};
let load = function (module) {
    // 读取的hello.js代码:
    function greet(name) {
        console.log('Hello, ' + name + '!');
    }
    
    module.exports = greet;
    // hello.js代码结束
    return module.exports;
};
let exported = load(module);
// 保存module:
save(module, exported);

可见,变量module是Node在加载js文件前准备的一个变量,并将其传入加载函数,我们在hello.js中可以直接使用变量module原因就在于它实际上是函数的一个参数:

javascript
module.exports = greet;

通过把参数module传递给load()函数,hello.js就顺利地把一个变量传递给了Node执行环境,Node会把module变量保存到某个地方。

由于Node保存了所有导入的module,当我们用require()获取module时,Node找到对应的module,把这个moduleexports变量返回,这样,另一个模块就顺利拿到了模块的输出:

javascript
const greet = require('./hello');

以上是Node实现JavaScript模块的一个简单的原理介绍。

module.exports vs exports

很多时候,你会看到,在Node环境中,有两种方法可以在一个模块中输出变量:

方法一:对module.exports赋值:

javascript
// hello.js

function hello() {
    console.log('Hello, world!');
}

function greet(name) {
    console.log('Hello, ' + name + '!');
}

module.exports = {
    hello: hello,
    greet: greet
};

方法二:直接使用exports:

javascript
// hello.js

function hello() {
    console.log('Hello, world!');
}

function greet(name) {
    console.log('Hello, ' + name + '!');
}

function hello() {
    console.log('Hello, world!');
}

exports.hello = hello;
exports.greet = greet;

但是你不可以直接对exports赋值:

javascript
// 代码可以执行,但是模块并没有输出任何变量:
exports = {
    hello: hello,
    greet: greet
};

如果你对上面的写法感到十分困惑,不要着急,我们来分析Node的加载机制:

首先,Node会把整个待加载的hello.js文件放入一个包装函数load中执行。在执行这个load()函数前,Node准备好了module变量:

javascript
let module = {
    id: 'hello',
    exports: {}
};

load()函数最终返回module.exports

javascript
let load = function (exports, module) {
    // hello.js的文件内容
    ...
    // load函数返回:
    return module.exports;
};

let exported = load(module.exports, module);

也就是说,默认情况下,Node准备的exports变量和module.exports变量实际上是同一个变量,并且初始化为空对象{},于是,我们可以写:

javascript
exports.foo = function () { return 'foo'; };
exports.bar = function () { return 'bar'; };

也可以写:

javascript
module.exports.foo = function () { return 'foo'; };
module.exports.bar = function () { return 'bar'; };

换句话说,Node默认给你准备了一个空对象{},这样你可以直接往里面加东西。

但是,如果我们要输出的是一个函数或数组,那么,只能给module.exports赋值:

javascript
module.exports = function () { return 'foo'; };

exports赋值是无效的,因为赋值后,module.exports仍然是空对象{}

结论

如果要输出一个键值对象{},可以利用exports这个已存在的空对象{},并继续在上面添加新的键值;

如果要输出一个函数或数组,必须直接对module.exports对象赋值。

所以我们可以得出结论:直接对module.exports赋值,可以应对任何情况:

javascript
module.exports = {
    foo: function () { return 'foo'; }
};

或者:

javascript
module.exports = function () { return 'foo'; };

最终,我们强烈建议使用module.exports = xxx的方式来输出模块变量,这样,你只需要记忆一种方法。

练习

编写hello.js,输出一个或多个函数;

编写main.js,引入hello模块,调用其函数。

参考源码

module

使用ESM模块

虽然Node.js从诞生起就支持模块,但JavaScript语言本身长期以来却一直没有模块功能,只能由CommonJS或其他AMD等模块系统来“模拟”。

随着ES 6标准的推出,JavaScript语言本身终于也迎来了原生内置的模块支持,称为ECMAScript Modules(简称ESM),不仅可以直接在浏览器中使用模块,也可以在Node.js中使用ESM模块。

不使用ESM模块时,我们用module.exports导出可供外部使用的JS对象,例如,以下模块导出了两个函数:

javascript
'use strict';
let s = 'Hello';

function out(prompt, name) {
    console.log(`${prompt}, ${name}!`);
}

function greet(name) {
    out(s, name);
}

function hi(name) {
    out('Hi', name);
}

module.exports = {
    greet: greet,
    hi: hi
};

要把上述代码改为ESM模块,我们用export标识需要导出的函数:

javascript
let s = 'Hello';

// out是模块内部函数,模块外部不可见:
function out(prompt, name) {
    console.log(`${prompt}, ${name}!`);
}

// greet是导出函数,可被外部调用:
export function greet(name) {
    out(s, name);
}

// hi是导出函数,可被外部调用:
export function hi(name) {
    out('Hi', name);
}

并将其保存为hello.mjs文件,注意扩展名不是.js,而是.mjs

可以再编写一个main.mjs文件来调用hello模块:

javascript
import { greet, hi } from './hello.mjs';

let name = 'Bob';
greet(name);
hi(name);

可见,ESM模块用export关键字导出一个JS对象,用import关键字导入一个模块的导出对象。

如果要实现类似如下代码的单个函数导出:

javascript
module.exports = greet;

则可以用export default导出:

javascript
export default function greet(name) {
    ...
}

相应的,导入代码修改为:

javascript
import greet from './hello.mjs';

细心的同学还注意到ESM模块文件第一行并没有'use strict',这是因为ESM模块默认启用严格模式,因此无需再手动声明'use strict'

浏览器加载ESM

对于浏览器来说,也可以直接使用ESM模块。当我们加载一个ESM模块时,需要用type="module"来表示:

html
<html>
<head>
    <script type="module" src="./example.js"></script>
    <script type="module">
        greet('Bob');
    </script>
</head>
...
</html>

或者直接使用import加载一个模块:

html
<html>
<head>
    <script type="module">
        import { greet } from './example.js';
        greet('Bob');
    </script>
</head>
...
</html>

练习

在Node环境中使用ESM模块:

下载练习

小结

使用JavaScript内置的原生模块时,用关键字exportimport来实现导出与导入;

ESM模块默认启用strict模式,无需声明'use strict'

基本模块

因为Node.js是运行在服务区端的JavaScript环境,服务器程序和浏览器程序相比,最大的特点是没有浏览器的安全限制了,而且,服务器程序必须能接收网络请求,读写文件,处理二进制内容,所以,Node.js内置的常用模块就是为了实现基本的服务器功能。这些模块在浏览器环境中是无法被执行的,因为它们的底层代码是用C/C++在Node.js运行环境中实现的。

global

在前面的JavaScript课程中,我们已经知道,JavaScript有且仅有一个全局对象,在浏览器中,叫window对象。而在Node.js环境中,也有唯一的全局对象,但不叫window,而叫global,这个对象的属性和方法也和浏览器环境的window不同。进入Node.js交互环境,可以直接输入:

plain
> global.console
Object [console] {
  log: [Function: log],
  warn: [Function: warn],
  dir: [Function: dir],
  time: [Function: time],
  ...
}

process

process也是Node.js提供的一个对象,它代表当前Node.js进程。通过process对象可以拿到许多有用信息:

plain
> process === global.process;
true
> process.version;
'v22.3.0'
> process.platform;
'darwin'
> process.arch;
'x64'
> process.cwd(); //返回当前工作目录
'/Users/michael'
> process.chdir('/private/tmp'); // 切换当前工作目录
undefined
> process.cwd();
'/private/tmp'

JavaScript程序是由事件驱动执行的单线程模型,Node.js也不例外。Node.js不断执行响应事件的JavaScript函数,直到没有任何响应事件的函数可以执行时,Node.js就退出了。

如果我们想要在下一次事件响应中执行代码,可以调用process.nextTick()

javascript
// test.js
// process.nextTick()将在下一轮事件循环中调用:
process.nextTick(function () {
    console.log('nextTick callback!');
});
console.log('nextTick was set!');

用Node执行上面的代码node test.js,你会看到,打印输出是:

plain
nextTick was set!
nextTick callback!

这说明传入process.nextTick()的函数不是立刻执行,而是要等到下一次事件循环。

Node.js进程本身的事件就由process对象来处理。如果我们响应exit事件,就可以在程序即将退出时执行某个回调函数:

javascript
// 程序即将退出时的回调函数:
process.on('exit', function (code) {
    console.log('about to exit with code: ' + code);
});

判断JavaScript执行环境

有很多JavaScript代码既能在浏览器中执行,也能在Node环境执行,但有些时候,程序本身需要判断自己到底是在什么环境下执行的,常用的方式就是根据浏览器和Node环境提供的全局变量名称来判断:

javascript
if (typeof(window) === 'undefined') {
    console.log('node.js');
} else {
    console.log('browser');
}

导入Node模块

Node内置了许多模块,可以从Node.js的在线文档查询所有模块信息。

crypto模块的randomInt()函数为例,导入模块有两种方法:

方法一:使用传统的require()

javascript
const { randomInt } = require('node:crypto');
const n = randomInt(0, 100); // 0~100之间的随机数
console.log(n);

方法二:使用import关键字导入ESM模块:

javascript
import { randomInt } from 'node:crypto';
const n = randomInt(0, 100); // 0~100之间的随机数
console.log(n);

后面,我们将介绍Node.js的常用内置模块。

fs

Node.js内置的fs模块就是文件系统模块,负责读写文件。

和所有其它JavaScript模块不同的是,fs模块同时提供了异步和同步的方法。

回顾一下什么是异步方法。因为JavaScript的单线程模型,执行IO操作时,JavaScript代码无需等待,而是传入回调函数后,继续执行后续JavaScript代码。比如jQuery提供的getJSON()操作:

javascript
$.getJSON('http://example.com/ajax', function (data) {
    console.log('IO结果返回后执行...');
});
console.log('不等待IO结果直接执行后续代码...');

而同步的IO操作则需要等待函数返回:

javascript
// 根据网络耗时,函数将执行几十毫秒~几秒不等:
let data = getJSONSync('http://example.com/ajax');

同步操作的好处是代码简单,缺点是程序将等待IO操作,在等待时间内,无法响应其它任何事件。而异步读取不用等待IO操作,但代码较麻烦。

异步读文件

按照JavaScript的标准,异步读取一个文本文件的代码如下:

javascript
// read-text-file-async.mjs
import { readFile } from 'node:fs';

console.log('BEGIN');

readFile('sample.txt', 'utf-8', function (err, data) {
    if (err) {
        console.log(err);
    } else {
        console.log(data);
    }
});

console.log('END');

请注意,sample.txt文件必须在当前目录下,且文件编码为utf-8

异步读取时,传入的回调函数接收两个参数,当正常读取时,err参数为nulldata参数为读取到的String。当读取发生错误时,err参数代表一个错误对象,dataundefined。这也是Node.js标准的回调函数:第一个参数代表错误信息,第二个参数代表结果。后面我们还会经常编写这种回调函数。

由于err是否为null就是判断是否出错的标志,所以通常的判断逻辑总是:

javascript
if (err) {
    // 出错了
} else {
    // 正常
}

执行上述代码,可以看到打印的内容如下:

plain
>>> BEGIN >>>
>>> END >>>
Sample file content...

因为异步读取,所以,先打印END后,才会执行回调函数,打印文件内容。

如果我们要读取的文件不是文本文件,而是二进制文件,怎么办?

下面的例子演示了如何读取一个图片文件:

javascript
import { readFile } from 'node:fs';

readFile('sample.png', function (err, data) {
    if (err) {
        console.log(err);
    } else {
        console.log(data instanceof Buffer); // true
        console.log(data); // Buffer(12451) [137, 80, 78, 71, 13, ...]
    }
});

当读取二进制文件时,不传入文件编码时,回调函数的data参数将返回一个Buffer对象。在Node.js中,Buffer对象就是一个包含零个或任意个字节的数组(注意和Array不同)。

Buffer对象可以和String作转换,例如,把一个Buffer对象转换成String:

javascript
// Buffer -> String
let text = data.toString('utf-8');
console.log(text);

或者把一个String转换成Buffer

javascript
// String -> Buffer
let buf = Buffer.from(text, 'utf-8');
console.log(buf);

同步读文件

除了标准的异步读取模式外,fs也提供相应的同步读取函数。同步读取的函数和异步函数相比,多了一个Sync后缀,并且不接收回调函数,函数直接返回结果。

fs模块同步读取一个文本文件的代码如下:

javascript
// read-text-file-sync.mjs
import { readFileSync } from 'node:fs';

console.log('BEGIN');

try {
    let s = readFileSync('sample.txt', 'utf-8');
    console.log(s);
} catch (err) {
    console.log(err);
}
console.log('END');

可见,原异步调用的回调函数的data被函数直接返回,函数名需要改为readFileSync,其它参数不变。

如果同步读取文件发生错误,则需要用try...catch捕获该错误。

写文件

将数据写入文件是通过fs.writeFile()实现的:

javascript
// write-file-async.mjs
import { writeFile } from 'node:fs';

let data = 'Hello, Node.js';
writeFile('output.txt', data, function (err) {
    if (err) {
        console.log(err);
    }
});

writeFile()的参数依次为文件名、数据和回调函数。如果传入的数据是String,默认按UTF-8编码写入文本文件,如果传入的参数是Buffer,则写入的是二进制文件。回调函数由于只关心成功与否,因此只需要一个err参数。

readFile()类似,writeFile()也有一个同步方法,叫writeFileSync()

stat

如果我们要获取文件大小,创建时间等信息,可以使用fs.stat(),它返回一个Stat对象,能告诉我们文件或目录的详细信息:

javascript
// read-stat-async.mjs
import { stat } from 'node:fs';

stat('sample.png', function (err, st) {
    if (err) {
        console.log(err);
    } else {
        // 是否是文件:
        console.log('isFile: ' + st.isFile());
        // 是否是目录:
        console.log('isDirectory: ' + st.isDirectory());
        if (st.isFile()) {
            // 文件大小:
            console.log('size: ' + st.size);
            // 创建时间, Date对象:
            console.log('birth time: ' + st.birthtime);
            // 修改时间, Date对象:
            console.log('modified time: ' + st.mtime);
        }
    }
});

运行结果如下:

plain
isFile: true
isDirectory: false
size: 12451
birth time: Mon Jun 17 2024 19:37:24 GMT+0800 (China Standard Time)
modified time: Mon Jun 17 2024 19:37:24 GMT+0800 (China Standard Time)

stat()也有一个对应的同步函数statSync(),请试着改写上述异步代码为同步代码。

使用Promise

我们在介绍JavaScript的Promise时,讲到通过async函数实现异步逻辑,代码更简单。

类似的,Node还提供Promise版本的fs,可以用如下代码在async函数中读取文件:

javascript
// async-read.mjs
import { readFile } from 'node:fs/promises';

async function readTextFile(path) {
    return await readFile(path, 'utf-8');
}

readTextFile('sample.txt').then(s => console.log(s));

在async函数中,用await调用fs/promises与同步方法类似,但代码却是异步执行的。

异步还是同步

fs模块中,提供同步方法是为了方便使用。那我们到底是应该用异步方法还是同步方法呢?

由于Node环境执行的JavaScript代码是服务器端代码,所以,绝大部分需要在服务器运行期反复执行业务逻辑的代码,必须使用异步代码,否则,同步代码在执行时期,服务器将停止响应,因为JavaScript只有一个执行线程。

服务器启动时如果需要读取配置文件,或者结束时需要写入到状态文件时,可以使用同步代码,因为这些代码只在启动和结束时执行一次,不影响服务器正常运行时的异步执行。

如果代码中编写了大量的async函数,那么通过await异步调用fs/promises模块更加方便。

参考源码

fs

stream

stream是Node.js提供的又一个仅在服务区端可用的模块,目的是支持“流”这种数据结构。

什么是流?流是一种抽象的数据结构。想象水流,当在水管中流动时,就可以从某个地方(例如自来水厂)源源不断地到达另一个地方(比如你家的洗手池)。我们也可以把数据看成是数据流,比如你敲键盘的时候,就可以把每个字符依次连起来,看成字符流。这个流是从键盘输入到应用程序,实际上它还对应着一个名字:标准输入流(stdin)。

如果应用程序把字符一个一个输出到显示器上,这也可以看成是一个流,这个流也有名字:标准输出流(stdout)。流的特点是数据是有序的,而且必须依次读取,或者依次写入,不能像Array那样随机定位。

stream

有些流用来读取数据,比如从文件读取数据时,可以打开一个文件流,然后从文件流中不断地读取数据。有些流用来写入数据,比如向文件写入数据时,只需要把数据不断地往文件流中写进去就可以了。

在Node.js中,流也是一个对象,我们只需要响应流的事件就可以了:data事件表示流的数据已经可以读取了,end事件表示这个流已经到末尾了,没有数据可以读取了,error事件表示出错了。

下面是一个从文件流读取文本内容的示例:

javascript
import { createReadStream } from 'node:fs';

// 打开流:
let rs = createReadStream('sample.txt', 'utf-8');

// 读取到数据:
rs.on('data', (chunk) => {
    console.log('---- chunk ----');
    console.log(chunk);
});

// 结束读取:
rs.on('end', () => {
    console.log('---- end ----');
});

// 出错:
rs.on('error', err => {
    console.log(err);
});

要注意,data事件可能会有多次,每次传递的chunk是流的一部分数据。

要以流的形式写入文件,只需要不断调用write()方法,最后以end()结束:

javascript
import { createWriteStream } from 'node:fs';

let ws = createWriteStream('output.txt', 'utf-8');
ws.write('使用Stream写入文本数据...\n');
ws.write('继续写入...\n');
ws.write('DONE.\n');
ws.end(); // 结束写入

// 写入二进制数据:
let b64array = [ ... ];

let ws2 = createWriteStream('output.png');
for (let b64 of b64array) {
    let buf = Buffer.from(b64, 'base64');
    ws2.write(buf); // 写入Buffer对象
}
ws2.end(); // 结束写入

所有可以读取数据的流都继承自stream.Readable,所有可以写入的流都继承自stream.Writable

pipe

就像可以把两个水管串成一个更长的水管一样,两个流也可以串起来。一个Readable流和一个Writable流串起来后,所有的数据自动从Readable流进入Writable流,这种操作叫pipe

在Node.js中,Readable流有一个pipe()方法,就是用来干这件事的:

javascript
rs.pipe(ws);

除了直接使用pipe()方法,Node还提供了pipeline功能,它可以将一个流输出到另一个流。以下是一个复制文件的程序:

javascript
import { createReadStream, createWriteStream } from "node:fs";
import { pipeline } from 'node:stream/promises';

async function copy(src, dest) {
    let rs = createReadStream(src);
    let ws = createWriteStream(dest);
    await pipeline(rs, ws);
}

copy('sample.txt', 'output.txt')
    .then(() => console.log('copied.'))
    .catch(err => console.log(err));

使用pipeline的好处是,它可以添加若干个转换器,即输入流经过若干转换后,再进入输出流。如果我们添加的转换器实现了gzip功能,那么实际上就可以把输入流自动压缩后进入输出流。

参考源码

stream

http

Node.js开发的目的就是为了用JavaScript编写Web服务器程序。因为JavaScript实际上已经统治了浏览器端的脚本,其优势就是有世界上数量最多的前端开发人员。如果已经掌握了JavaScript前端开发,再学习一下如何将JavaScript应用在后端开发,就是名副其实的全栈了。

HTTP协议

要理解Web服务器程序的工作原理,首先,我们要对HTTP协议有基本的了解。如果你对HTTP协议不太熟悉,先看一看HTTP协议简介

HTTP服务器

要开发HTTP服务器程序,从头处理TCP连接,解析HTTP是不现实的。这些工作实际上已经由Node.js自带的http模块完成了。应用程序并不直接和HTTP协议打交道,而是操作http模块提供的requestresponse对象。

request对象封装了HTTP请求,我们调用request对象的属性和方法就可以拿到所有HTTP请求的信息;

response对象封装了HTTP响应,我们操作response对象的方法,就可以把HTTP响应返回给浏览器。

用Node.js实现一个HTTP服务器程序非常简单。我们来实现一个最简单的Web程序hello.js,它对于所有请求,都返回Hello world!

javascript
// 导入http模块:
import http from 'node:http';

// 创建http server,并传入回调函数:
const server = http.createServer((request, response) => {
    // 回调函数接收request和response对象,
    // 获得HTTP请求的method和url:
    console.log(request.method + ': ' + request.url);
    // 将HTTP响应200写入response, 同时设置Content-Type: text/html:
    response.writeHead(200, {'Content-Type': 'text/html'});
    // 将HTTP响应的HTML内容写入response:
    response.end('<h1>Hello world!</h1>');
});

// 出错时返回400:
server.on('clientError', (err, socket) => {
  socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});

// 让服务器监听8080端口:
server.listen(8080);
console.log('Server is running at http://127.0.0.1:8080/');

在命令提示符下运行该程序,可以看到以下输出:

plain
$ node simple-server.mjs 
Server is running at http://127.0.0.1:8080/

不要关闭命令提示符,直接打开浏览器输入http://localhost:8080,即可看到服务器响应的内容:

http-page

同时,在命令提示符窗口,可以看到程序打印的请求信息:

plain
GET: /
GET: /favicon.ico

这就是我们编写的第一个HTTP服务器程序!

文件服务器

让我们继续扩展一下上面的Web程序。我们可以设定一个目录,然后让Web程序变成一个文件服务器。要实现这一点,我们只需要解析request.url中的路径,然后在本地找到对应的文件,把文件内容发送出去就可以了。

观察打印的request.url,它实际上是浏览器请求的路径和参数,如:

  • /
  • /index.html
  • /hello?name=bob

解析出path部分可以直接用URL对象:

javascript
let url = new URL('http://localost' + '/index.html?v=1');
let pathname = url.pathname;
console.log(pathname); // index.html

处理本地文件目录需要使用Node.js提供的path模块,它可以方便地构造目录:

javascript
import path from 'node:path';

// 解析当前目录:
let workDir = path.resolve('.'); // '/Users/michael'

// 组合完整的文件路径:当前目录+'pub'+'index.html':
let filePath = path.join(workDir, 'pub', 'index.html');
// '/Users/michael/pub/index.html'

使用path模块可以正确处理操作系统相关的文件路径。在Windows系统下,返回的路径类似于C:\Users\michael\static\index.html,这样,我们就不关心怎么拼接路径了。

最后,我们实现一个文件服务器simple-file-server.js

javascript
// 导入http模块:
import http from 'node:http';
import path from 'node:path';
import { createReadStream } from 'node:fs';
import { stat } from 'node:fs/promises';

// 设定www根目录为当前目录:
const wwwRoot = path.resolve('.');
console.log(`set www root: ${wwwRoot}`);

// 根据扩展名确定MIME类型:
function guessMime(pathname) {
    // FIXME:
    return 'text/html';
}

// 创建http file server,并传入回调函数:
const server = http.createServer((request, response) => {
    // 获得HTTP请求的method和url:
    console.log(request.method + ': ' + request.url);
    if (request.method !== 'GET') {
        response.writeHead(400, { 'Content-Type': 'text/html' });
        response.end('<h1>400 Bad Request</h1>');
    } else {
        // 解析path: 
        let url = new URL(`http://localhost${request.url}`);
        let pathname = url.pathname;
        let filepath = path.join(wwwRoot, pathname);
        // TODO: 必要的安全检查
        // 检查文件状态:
        stat(filepath).then(st => {
            if (st.isFile()) {
                console.log('200 OK');
                // 发送200响应:
                response.writeHead(200, { 'Content-Type': guessMime(pathname) });
                // 将文件流导向response:
                createReadStream(filepath).pipe(response);
            } else {
                console.log('404 Not Found');
                response.writeHead(404, { 'Content-Type': 'text/html' });
                response.end('<h1>404 Not Found</h1>');
            }
        }).catch(err => {
            console.log('404 Not Found');
            response.writeHead(404, { 'Content-Type': 'text/html' });
            response.end('<h1>404 Not Found</h1>');
        });
    }
});

// 出错时返回400:
server.on('clientError', (err, socket) => {
    socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
});

// 让服务器监听8080端口:
server.listen(8080);
console.log('Server is running at http://127.0.0.1:8080/');

没有必要手动读取文件内容。由于response对象本身是一个Writable Stream,直接用pipe()方法就实现了自动读取文件内容并输出到HTTP响应。

在命令行运行node simple-file-server.mjs,然后在浏览器中输入http://localhost:8080/index.html

http-index-page

只要当前目录下存在文件index.html,服务器就可以把文件内容发送给浏览器。观察控制台输出:

plain
GET: /index.html
200 OK
GET: /next/hello.png
200 OK
GET: /favicon.ico
200 OK

第一个请求是浏览器请求/页面,后续请求是浏览器解析HTML后发送的其它资源请求。

练习

在浏览器输入http://localhost:8080/时,会返回404,原因是程序识别出HTTP请求的不是文件,而是目录。请修改simple-file-server.mjs,如果遇到请求的路径是目录,则自动在目录下依次搜索index.htmldefault.html,如果找到了,就返回HTML文件的内容。

参考源码

crypto

crypto模块的目的是为了提供通用的加密和哈希算法。用纯JavaScript代码实现这些功能不是不可能,但速度会非常慢。Nodejs用C/C++实现这些算法后,通过cypto这个模块暴露为JavaScript接口,这样用起来方便,运行速度也快。

MD5和SHA1

MD5是一种常用的哈希算法,用于给任意数据一个“签名”。这个签名通常用一个十六进制的字符串表示:

javascript
import crypto from 'node:crypto';

const hash = crypto.createHash('md5');

// 可任意多次调用update():
hash.update('Hello, world!');
hash.update('Hello, nodejs!');

console.log(hash.digest('hex')); // 7e1977739c748beac0c0fd14fd26a544

update()方法默认字符串编码为UTF-8,也可以传入Buffer。

如果要计算SHA1,只需要把'md5'改成'sha1',就可以得到SHA1的结果。还可以使用更安全的sha256sha512

Hmac

Hmac算法也是一种哈希算法,它可以利用MD5或SHA1等哈希算法。不同的是,Hmac还需要一个密钥:

javascript
import crypto from 'node:crypto';

const hmac = crypto.createHmac('sha256', 'secret-key');

hmac.update('Hello, world!');
hmac.update('Hello, nodejs!');

console.log(hmac.digest('hex')); // 80f7e22570...

只要密钥发生了变化,那么同样的输入数据也会得到不同的签名,因此,可以把Hmac理解为用随机数“增强”的哈希算法。

AES

AES是一种常用的对称加密算法,加解密都用同一个密钥。crypto模块提供了AES支持,但是需要自己封装好函数,便于使用:

javascript
import crypto from 'node:crypto';

function aes_encrypt(key, iv, msg) {
    const cipher = crypto.createCipheriv('aes-256-cbc', key, iv);
    // input encoding: utf8
    // output encoding: hex
    let encrypted = cipher.update(msg, 'utf8', 'hex');
    encrypted += cipher.final('hex');
    return encrypted;
}

function aes_decrypt(key, iv, encrypted) {
    const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv);
    let decrypted = decipher.update(encrypted, 'hex', 'utf8');
    decrypted += decipher.final('utf8');
    return decrypted;
}

// key的长度必须为32bytes:
let key = 'Passw0rdPassw0rdPassw0rdPassw0rd';
// iv的长度必须为16bytes:
let iv = 'a1b2c3d4e5f6g7h8';
let msg = 'Hello, world!';
// 加密:
let encrypted_msg = aes_encrypt(key, iv, msg);
// 解密:
let decrypted_msg = aes_decrypt(key, iv, encrypted_msg);

console.log(`AES encrypt: ${encrypted_msg}`);
console.log(`AES decrypt: ${decrypted_msg}`);

运行结果如下:

text
AES encrypt: 11cd65e5fe7e7448b491efabee2f326a
AES decrypt: Hello, world!

可以看出,加密后的字符串通过解密又得到了原始内容。

注意到AES有很多不同的算法,如aes192aes-128-ecbaes-256-cbc等,AES除了密钥外还可以指定IV(Initial Vector),不同的系统只要IV不同,用相同的密钥加密相同的数据得到的加密结果也是不同的。加密结果通常有两种表示方法:hex和base64,这些功能Node.js全部都支持,但是在应用中要注意,如果加解密双方一方用Nodejs,另一方用Java、PHP等其它语言,需要仔细测试。如果无法正确解密,要确认双方是否遵循同样的AES算法,密钥和IV是否相同,加密后的数据是否统一为hex或base64格式。

Diffie-Hellman

DH算法是一种密钥交换协议,它可以让双方在不泄漏密钥的情况下协商出一个密钥来。DH算法基于数学原理,比如小明和小红想要协商一个密钥,可以这么做:

  1. 小明先选一个素数和一个底数,例如,素数p=97,底数g=5(底数是p的一个原根),再选择一个秘密整数a=123,计算A=g^a mod p=34,然后大声告诉小红:p=97,g=5,A=34
  2. 小红收到小明发来的pgA后,也选一个秘密整数b=456,然后计算B=g^b mod p=75,并大声告诉小明:B=75
  3. 小明自己计算出s=B^a mod p=22,小红也自己计算出s=A^b mod p=22,因此,最终协商的密钥s22

在这个过程中,密钥22并不是小明告诉小红的,也不是小红告诉小明的,而是双方协商计算出来的。第三方只能知道p=97g=5A=34B=75,由于不知道双方选的秘密整数a=123b=456,因此无法计算出密钥22

用crypto模块实现DH算法如下:

javascript
import crypto from 'node:crypto';

// xiaoming's keys:
let ming = crypto.createDiffieHellman(512);
let ming_keys = ming.generateKeys();

let prime = ming.getPrime();
let generator = ming.getGenerator();

console.log('Prime: ' + prime.toString('hex'));
console.log('Generator: ' + generator.toString('hex'));

// xiaohong's keys:
let hong = crypto.createDiffieHellman(prime, generator);
let hong_keys = hong.generateKeys();

// exchange and generate secret:
let ming_secret = ming.computeSecret(hong_keys);
let hong_secret = hong.computeSecret(ming_keys);

// print secret:
console.log('Secret of Xiao Ming: ' + ming_secret.toString('hex'));
console.log('Secret of Xiao Hong: ' + hong_secret.toString('hex'));

运行后,可以得到如下输出:

plain
Prime: a8224c...deead3
Generator: 02
Secret of Xiao Ming: 695308...d519be
Secret of Xiao Hong: 695308...d519be

注意每次输出都不一样,因为素数的选择是随机的。

RSA

RSA算法是一种非对称加密算法,即由一个私钥和一个公钥构成的密钥对,通过私钥加密,公钥解密,或者通过公钥加密,私钥解密。其中,公钥可以公开,私钥必须保密。

RSA算法是1977年由Ron Rivest、Adi Shamir和Leonard Adleman共同提出的,所以以他们三人的姓氏的头字母命名。

当小明给小红发送信息时,可以用小明自己的私钥加密,小红用小明的公钥解密,也可以用小红的公钥加密,小红用她自己的私钥解密,这就是非对称加密。相比对称加密,非对称加密只需要每个人各自持有自己的私钥,同时公开自己的公钥,不需要像AES那样由两个人共享同一个密钥。

在使用Node进行RSA加密前,我们先要准备好私钥和公钥。

首先,在命令行执行以下命令以生成一个RSA密钥对:

plain
openssl genrsa -aes256 -out rsa-key.pem 2048

根据提示输入密码,这个密码是用来加密RSA密钥的,加密方式指定为AES256,生成的RSA的密钥长度是2048位。执行成功后,我们获得了加密的rsa-key.pem文件。

第二步,通过上面的rsa-key.pem加密文件,我们可以导出原始的私钥,命令如下:

plain
openssl rsa -in rsa-key.pem -outform PEM -out rsa-prv.pem

输入第一步的密码,我们获得了解密后的私钥。

类似的,我们用下面的命令导出原始的公钥:

plain
openssl rsa -in rsa-key.pem -outform PEM -pubout -out rsa-pub.pem

这样,我们就准备好了原始私钥文件rsa-prv.pem和原始公钥文件rsa-pub.pem,编码格式均为PEM。

下面,使用crypto模块提供的方法,即可实现非对称加解密。

首先,我们用私钥加密,公钥解密:

javascript
import fs from 'node:fs';
import crypto from 'node:crypto';

// 从文件加载key:
function loadKey(file) {
    // key实际上就是PEM编码的字符串:
    return fs.readFileSync(file, 'utf8');
}

let
    prvKey = loadKey('./rsa-prv.pem'),
    pubKey = loadKey('./rsa-pub.pem'),
    message = 'Hello, world!';

// 使用私钥加密:
let enc_by_prv = crypto.privateEncrypt(prvKey, Buffer.from(message, 'utf8'));
console.log(enc_by_prv.toString('hex'));

let dec_by_pub = crypto.publicDecrypt(pubKey, enc_by_prv);
console.log(dec_by_pub.toString('utf8'));

执行后,可以得到解密后的消息,与原始消息相同。

接下来我们使用公钥加密,私钥解密:

javascript
// 使用公钥加密:
let enc_by_pub = crypto.publicEncrypt(pubKey, Buffer.from(message, 'utf8'));
console.log(enc_by_pub.toString('hex'));

// 使用私钥解密:
let dec_by_prv = crypto.privateDecrypt(prvKey, enc_by_pub);
console.log(dec_by_prv.toString('utf8'));

执行得到的解密后的消息仍与原始消息相同。

如果我们把message字符串的长度增加到很长,例如1M,这时,执行RSA加密会得到一个类似这样的错误:data too large for key size,这是因为RSA加密的原始信息必须小于Key的长度。那如何用RSA加密一个很长的消息呢?实际上,RSA并不适合加密大数据,而是先生成一个随机的AES密码,用AES加密原始信息,然后用RSA加密AES口令,这样,实际使用RSA时,给对方传的密文分两部分,一部分是AES加密的密文,另一部分是RSA加密的AES口令。对方用RSA先解密出AES口令,再用AES解密密文,即可获得明文。

证书

crypto模块也可以处理数字证书。数字证书通常用在SSL连接,也就是Web的https连接。一般情况下,https连接只需要处理服务器端的单向认证,如无特殊需求(例如自己作为Root给客户发认证证书),建议用反向代理服务器如Nginx等Web服务器去处理证书。

参考源码

Web开发

最早的软件都是运行在大型机上的,软件使用者通过“哑终端”登陆到大型机上去运行软件。后来随着PC机的兴起,软件开始主要运行在桌面上,而数据库这样的软件运行在服务器端,这种Client/Server模式简称CS架构。

随着互联网的兴起,人们发现,CS架构不适合Web,最大的原因是Web应用程序的修改和升级非常迅速,而CS架构需要每个客户端逐个升级桌面App,因此,Browser/Server模式开始流行,简称BS架构。

在BS架构下,客户端只需要浏览器,应用程序的逻辑和数据都存储在服务器端。浏览器只需要请求服务器,获取Web页面,并把Web页面展示给用户即可。

当然,Web页面也具有极强的交互性。由于Web页面是用HTML编写的,而HTML具备超强的表现力,并且,服务器端升级后,客户端无需任何部署就可以使用到新的版本,因此,BS架构迅速流行起来。

今天,除了重量级的软件如Office,Photoshop等,大部分软件都以Web形式提供。比如,新浪提供的新闻、博客、微博等服务,均是Web应用。

Web应用开发可以说是目前软件开发中最重要的部分。Web开发也经历了好几个阶段:

静态Web页面:由文本编辑器直接编辑并生成静态的HTML页面,如果要修改Web页面的内容,就需要再次编辑HTML源文件,早期的互联网Web页面就是静态的;

CGI:由于静态Web页面无法与用户交互,比如用户填写了一个注册表单,静态Web页面就无法处理。要处理用户发送的动态数据,出现了Common Gateway Interface,简称CGI,用C/C++编写。

ASP/JSP/PHP:由于Web应用特点是修改频繁,用C/C++这样的低级语言非常不适合Web开发,而脚本语言由于开发效率高,与HTML结合紧密,因此,迅速取代了CGI模式。ASP是微软推出的用VBScript脚本编程的Web开发技术,而JSP用Java来编写脚本,PHP本身则是开源的脚本语言。

MVC:为了解决直接用脚本语言嵌入HTML导致的可维护性差的问题,Web应用也引入了Model-View-Controller的模式,来简化Web开发。ASP发展为ASP.Net,JSP和PHP也有一大堆MVC框架。

目前,Web开发技术仍在快速发展中,异步开发、新的MVVM前端技术层出不穷。

由于Node.js把JavaScript引入了服务器端,因此,原来必须使用PHP/Java/C#/Python/Ruby等其他语言来开发服务器端程序,现在可以使用Node.js开发了!

用Node.js开发Web服务器端,有几个显著的优势:

一是后端语言也是JavaScript,以前掌握了前端JavaScript的开发人员,现在可以同时编写后端代码;

二是前后端统一使用JavaScript,就没有切换语言的障碍了;

三是速度快,非常快!这得益于Node.js天生是异步的。

在Node.js诞生后的短短几年里,出现了无数种Web框架、ORM框架、模版引擎、测试框架、自动化构建工具,数量之多,即使是JavaScript老司机,也不免眼花缭乱。

常见的Web框架包括:ExpressSails.jskoaMeteorDerbyJSTotal.jsrestify……

ORM框架比Web框架要少一些:SequelizeORM2Bookshelf.jsObjection.js……

模版引擎PK:JadeEJSSwigNunjucksdoT.js……

测试框架包括:MochaExpressoUnit.jsKarma……

构建工具有:GruntGulpWebpack……

目前,在npm上已发布的开源Node.js模块数量超过了30万个。

有选择恐惧症的朋友,看到这里可以洗洗睡了。

好消息是这个教程已经帮你选好了,你只需要跟着教程一条道走到黑就可以了。

koa

koa是Express的下一代基于Node.js的web框架,目前有1.x和2.0两个版本。

历史

Express

Express是第一代最流行的web框架,它对Node.js的http进行了封装,用起来如下:

javascript
let express = require('express');
let app = express();

app.get('/', function (req, res) {
    res.send('Hello World!');
});

app.listen(3000, function () {
    console.log('Example app listening on port 3000!');
});

虽然Express的API很简单,但是它是基于ES5的语法,要实现异步代码,只有一个方法:回调。如果异步嵌套层次过多,代码写起来就非常难看:

javascript
app.get('/test', function (req, res) {
    fs.readFile('/file1', function (err, data) {
        if (err) {
            res.status(500).send('read file1 error');
        }
        fs.readFile('/file2', function (err, data) {
            if (err) {
                res.status(500).send('read file2 error');
            }
            res.type('text/plain');
            res.send(data);
        });
    });
});

虽然可以用async这样的库来组织异步代码,但是用回调写异步实在是太痛苦了!

koa 1.x

随着新版Node.js开始支持ES6,Express的团队又基于ES6的generator重新编写了下一代web框架koa。和Express相比,koa 1.x使用generator实现异步,代码看起来像同步的:

javascript
let koa = require('koa');
let app = koa();

app.use('/test', function *() {
    yield doReadFile1();
    let data = yield doReadFile2();
    this.body = data;
});

app.listen(3000);

用generator实现异步比回调简单了不少,但是generator的本意并不是异步。Promise才是为异步设计的,但是Promise的写法……想想就复杂。为了简化异步代码,JavaScript引入了新的关键字asyncawait,可以轻松地把一个function变为异步模式:

javascript
async function () {
    let data = await fs.read('/file1');
}

这是JavaScript标准的异步代码,非常简洁,并且易于使用。

koa 2.x

koa团队并没有止步于koa 1.x,他们又开发了koa 2,和koa 1相比,koa 2完全使用Promise并配合async来实现异步。

koa 2.x的代码看上去像这样:

javascript
app.use(async (ctx, next) => {
    await next();
    let data = await doReadFile();
    ctx.response.type = 'text/plain';
    ctx.response.body = data;
});

选择哪个版本?

为了紧跟时代潮流,教程将使用最新的koa 2.x开发!

koa入门

创建koa工程

首先,我们创建一个目录hello-koa,作为koa工程根目录。

在根目录下执行npm install koa,我们就在当前目录下安装好了koa及其依赖项,执行完成后目录结构如下:

hello-koa/
├── node_modules/      <-- koa以及所有依赖项
├── package-lock.json
└── package.json       <-- npm描述文件

package.json包含了npm的依赖信息,以及项目描述等信息,package-lock.json是针对当前所有依赖的一个快照,目的是锁定各个依赖项的版本号。

我们打开package.json,内容如下:

json
{
    "dependencies": {
        "koa": "^2.15.3"
    }
}

它只有一个koa依赖,是由命令npm install koa写入的。

node_modules是所有依赖项安装的地方,可以随时删除node_modules目录,然后用npm install重新安装。

直接运行npm install则根据package.jsondependencies信息下载安装依赖项,运行npm install xyz则将xyz添加到dependencies然后再安装xyz及其依赖项。因此,熟练的开发者可以先自己编辑dependencies内容,然后删除node_modules后重新安装所有依赖项。此外,package.jsonpackage-lock.json应当添加至版本控制系统中,而node_modules则无需添加。

我们手动添加如下信息至package.json

json
{
    "name": "hello-koa",
    "version": "1.0",
    "description": "Hello koa webapp.",
    "type": "module",
    "dependencies": {
        "koa": "^2.15.3"
    }
}

其中,nameversiondescription均可任意设置,typemodule表示以ESM模块执行。

紧接着,我们用VS Code打开hello-koa目录,创建app.mjs文件,输入以下代码:

javascript
// 导入koa,注意导入的是大写开头的class:
import Koa from 'koa';

// 创建一个koa实例表示webapp本身:
const app = new Koa();

// 对于任何请求,app将调用该异步函数处理请求:
app.use(async (ctx, next) => {
    await next();
    // 设置响应类型和文本:
    ctx.response.type = 'text/html';
    ctx.response.body = '<h1>Hello Koa!</h1>';
});

// 在端口3000监听:
app.listen(3000);
console.log('app started at port 3000...');

对于每一个http请求,koa将调用我们传入的异步函数来处理:

javascript
async (ctx, next) => {
    await next();
    // 设置response的Content-Type:
    ctx.response.type = 'text/html';
    // 设置response的内容:
    ctx.response.body = '<h1>Hello Koa!</h1>';
}

其中,参数ctx是由koa传入的封装了request和response的变量,我们可以通过它访问request和response,next是koa传入的将要处理的下一个异步函数。

上面的异步函数中,我们首先用await next();处理下一个异步函数,然后,设置response的Content-Type和内容。

现在,我们的工程结构如下:

hello-koa/
├── node_modules/
├── app.mjs
├── package-lock.json
└── package.json

在命令行执行node app.mjs就启动了Web服务器。我们打开浏览器,输入http://localhost:3000,即可看到效果:

koa-browser

koa middleware

让我们再仔细看看koa的执行逻辑。核心代码是:

javascript
app.use(async (ctx, next) => {
    await next();
    ctx.response.type = 'text/html';
    ctx.response.body = '<h1>Hello Koa!</h1>';
});

每收到一个http请求,koa就会调用通过app.use()注册的async函数,并传入ctxnext参数。

我们可以对ctx操作,并设置返回内容。但是为什么要调用await next()

原因是koa把很多async函数组成一个处理链,每个async函数都可以做一些自己的事情,然后用await next()来调用下一个async函数。我们把每个async函数称为middleware,这些middleware可以组合起来,完成很多有用的功能。

例如,可以用以下3个middleware组成处理链,依次打印日志,记录处理时间,输出HTML:

javascript
app.use(async (ctx, next) => {
    console.log(`${ctx.request.method} ${ctx.request.url}`); // 打印URL
    await next(); // 调用下一个middleware
});

app.use(async (ctx, next) => {
    const start = Date.now(); // 当前时间
    await next(); // 调用下一个middleware
    const ms = Date.now() - start; // 耗费时间
    console.log(`Time: ${ms}ms`); // 打印耗费时间
});

app.use(async (ctx, next) => {
    await next();
    ctx.response.type = 'text/html';
    ctx.response.body = '<h1>Hello Koa!</h1>';
});

middleware的顺序很重要,也就是调用app.use()的顺序决定了middleware的顺序。

此外,如果一个middleware没有调用await next(),会怎么办?答案是后续的middleware将不再执行了。这种情况也很常见,例如,一个检测用户权限的middleware可以决定是否继续处理请求,还是直接返回403错误:

javascript
app.use(async (ctx, next) => {
    if (await checkUserPermission(ctx)) {
        await next();
    } else {
        ctx.response.status = 403;
    }
});

理解了middleware,我们就已经会用koa了!

最后注意ctx对象有一些简写的方法,例如ctx.url相当于ctx.request.urlctx.type相当于ctx.response.type

参考源码

hello-koa

处理URL

在hello-koa工程中,我们处理http请求一律返回相同的HTML,这样虽然非常简单,但是用浏览器一测,随便输入任何URL都会返回相同的网页。

buduijin

正常情况下,我们应该对不同的URL调用不同的处理函数,这样才能返回不同的结果。例如像这样写:

javascript
app.use(async (ctx, next) => {
    if (ctx.request.path === '/') {
        ctx.response.body = 'index page';
    } else {
        await next();
    }
});

app.use(async (ctx, next) => {
    if (ctx.request.path === '/test') {
        ctx.response.body = 'TEST page';
    } else {
        await next();
    }
});

app.use(async (ctx, next) => {
    if (ctx.request.path === '/error') {
        ctx.response.body = 'ERROR page';
    } else {
        await next();
    }
});

这么写是可以运行的,但是好像有点蠢。

应该有一个能集中处理URL的middleware,它根据不同的URL调用不同的处理函数,这样,我们才能专心为每个URL编写处理函数。

@koa/router

为了处理URL,我们需要引入@koa/router这个middleware,让它负责处理URL映射。

我们把上一节的hello-koa工程复制一份,重命名为url-koa

先用命令npm install @koa/router安装并在package.json中添加依赖项,接下来,我们修改app.mjs,使用@koa/router来处理URL:

javascript
import Koa from 'koa';
import Router from '@koa/router';

const app = new Koa();
const router = new Router();

// log url:
app.use(async (ctx, next) => {
    console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);
    await next();
});

// 解析request.body:
app.use(bodyParser());

// 设置路由: 处理 /
router.get('/', async (ctx, next) => {
    ctx.response.type = 'text/html';
    ctx.response.body = '<h1>Index Page</h1>';
});

// 设置路由: 处理 /hello/:name
router.get('/hello/:name', async (ctx, next) => {
    // 获取:name参数:
    let s = ctx.params.name;
    ctx.response.type = 'text/html';
    ctx.response.body = `<h1>Hello, ${s}</h1>`;
});

// 使用router:
app.use(router.routes());

app.listen(3000);
console.log('app started at port 3000...');

我们使用router.get('/path', async fn)来注册一个GET请求。可以在请求路径中使用带变量的/hello/:name,变量可以通过ctx.params.name访问。

再运行app.mjs,我们就可以测试不同的URL:

输入首页:http://localhost:3000/

url-index

输入:http://localhost:3000/hello/Bob

url-hello

处理post请求

router.get('/path', async fn)处理的是get请求。如果要处理post请求,可以用router.post('/path', async fn)

用post请求处理URL时,我们会遇到一个问题:post请求通常会发送一个表单,或者JSON,它作为request的body发送,但无论是Node.js提供的原始request对象,还是koa提供的request对象,都不提供解析request的body的功能!

所以,我们又需要引入另一个middleware来解析原始request请求,然后,把解析后的参数,绑定到ctx.request.body中。@koa/bodyparser就是用来干这个活的。

我们用命令npm install @koa/bodyparser安装并在package.json中添加依赖项,然后,修改app.mjs,引入@koa/bodyparser

javascript
import { bodyParser } from '@koa/bodyparser';

在合适的位置加上:

javascript
app.use(bodyParser());

由于middleware的顺序很重要,这个@koa/bodyparser必须在router之前被注册到app对象上。

现在我们就可以处理post请求了。写一个简单的登录表单:

javascript
router.get('/', async (ctx, next) => {
    ctx.response.type = 'text/html';
    ctx.response.body = `
<h1>Index Page</h1>
<form action="/signin" method="post">
    <p>Name: <input name="name" value="koa"></p>
    <p>Password: <input name="password" type="password"></p>
    <p><button type="submit">Submit</button></p>
</form>
`;
});

router.post('/signin', async (ctx, next) => {
    let name = ctx.request.body.name || '';
    let password = ctx.request.body.password || '';
    console.log(`try signin: ${name}, password: ${password}`);
    if (name === 'koa' && password === '12345') {
        ctx.response.type = 'text/html';
        ctx.response.body = `<h1>Welcome, ${name}!</h1>`;
    } else {
        ctx.response.type = 'text/html';
        ctx.response.body = '<h1>Signin failed!</h1><p><a href="/">Retry</a></p>';
    }
});

注意到我们用let name = ctx.request.body.name || ''拿到表单的name字段,如果该字段不存在,默认值设置为''

类似的,put、delete、head请求也可以由router处理。

重构

现在,我们已经可以处理不同的URL了,但是看看app.mjs,总觉得还是有点不对劲。

still-buduijin

所有的URL处理函数都放到app.mjs里显得很乱,而且,每加一个URL,就需要修改app.mjs。随着URL越来越多,app.mjs就会越来越长。

如果能把URL处理函数集中到某个js文件,或者某几个js文件中就好了,然后让app.mjs自动导入所有处理URL的函数。这样,代码一分离,逻辑就显得清楚了。最好是这样:

url2-koa/
├── controller/
│   ├── hello.mjs   <-- 处理/hello/:name
│   └── signin.mjs  <-- 处理/signin
├── app.mjs
├── package-lock.json
└── package.json

于是我们把url-koa复制一份,重命名为url2-koa,准备重构这个项目。

我们先在controller目录下编写signin.mjs

javascript
// GET /
async function index(ctx, next) {
    ctx.response.type = 'text/html';
    ctx.response.body = `
<h1>Index Page</h1>
<form action="/signin" method="post">
    <p>Name: <input name="name" value="koa"></p>
    <p>Password: <input name="password" type="password"></p>
    <p><button type="submit">Submit</button></p>
</form>
`;
}

// POST /signin
async function signin(ctx, next) {
    let name = ctx.request.body.name || '';
    let password = ctx.request.body.password || '';
    console.log(`try signin: ${name}, password: ${password}`);
    if (name === 'koa' && password === '12345') {
        ctx.response.type = 'text/html';
        ctx.response.body = `<h1>Welcome, ${name}!</h1>`;
    } else {
        ctx.response.type = 'text/html';
        ctx.response.body = '<h1>Signin failed!</h1><p><a href="/">Retry</a></p>';
    }
}

// 导出处理函数:
export default {
    'GET /': index,
    'POST /signin': signin
};

这个signin.mjs通过export default把两个URL处理函数暴露出来。

类似的,hello.mjs把一个URL处理函数暴露出来:

javascript
async function hello(ctx, next) {
    // 获取:name参数:
    let s = ctx.params.name;
    ctx.response.type = 'text/html';
    ctx.response.body = `<h1>Hello, ${s}</h1>`;
}

export default {
    'GET /hello/:name': hello
}

现在,我们修改app.mjs,让它自动扫描controller目录,找到所有js文件,导入,然后注册每个URL:

javascript
// 扫描controller目录:
const dirname = path.dirname(fileURLToPath(import.meta.url));
console.log(`scan dir ${dirname}...`);
// 列举 *.mjs 文件:
let files = readdirSync(path.join(dirname, 'controller')).filter(f => f.endsWith('.mjs'));
for (let file of files) {
    // 导入模块:
    console.log(`import controller/${file}...`);
    let { default: mapping } = await import(`./controller/${file}`);
    // 把每个URL映射添加到router:
    for (let url in mapping) {
        if (url.startsWith('GET ')) {
            // 类似 'GET /hello/:name'
            let p = url.substring(4);
            router.get(p, mapping[url]);
            console.log(`mapping: GET ${p}`);
        } else if (url.startsWith('POST ')) {
            // 类似 'POST /signin'
            let p = url.substring(5);
            router.post(p, mapping[url]);
            console.log(`mapping: POST ${p}`);
        } else {
            console.warn(`invalid mapping: ${url}`);
        }
    }
}

Controller Middleware

最后,我们把扫描controller目录和创建router的代码从app.mjs中提取出来,作为一个简单的middleware使用,命名为controller.mjs

javascript
// controller.mjs:
async function scan(router, controllerDir) {
    // 扫描controller目录:
    const dirname = path.dirname(fileURLToPath(import.meta.url));
    console.log(`scan dir ${dirname}...`);
    let files = readdirSync(path.join(dirname, controllerDir)).filter(f => f.endsWith('.mjs'));
    for (let file of files) {
        // 导入模块:
        console.log(`import controller/${file}...`);
        let { default: mapping } = await import(`./${controllerDir}/${file}`);
        // 把每个URL映射添加到router:
        for (let url in mapping) {
            if (url.startsWith('GET ')) {
                let p = url.substring(4);
                router.get(p, mapping[url]);
                console.log(`mapping: GET ${p}`);
            } else if (url.startsWith('POST ')) {
                let p = url.substring(5);
                router.post(p, mapping[url]);
                console.log(`mapping: POST ${p}`);
            } else {
                console.warn(`invalid mapping: ${url}`);
            }
        }
    }
}

// 默认扫描目录为 controller:
export default async function (controllerDir = 'controller') {
    const router = new Router();
    await scan(router, controllerDir);
    return router.routes();
}

这样一来,我们在app.mjs的代码又简化了:

javascript
import controller from './controller.mjs';
...
// 使用controller(), 注意controller模块导出的是async函数,要通过await调用:
app.use(await controller());
...

经过重新整理后的工程url2-koa目前具备非常好的模块化,所有处理URL的函数按功能组存放在controller目录,今后我们也只需要不断往这个目录下加东西就可以了,app.mjs保持不变。

最后我们整理一下koa处理一个HTTP请求的流程:




┌─────────────────────┐
│log:                 │
│async(ctx,next) {...}│
└─────────────────────┘


┌─────────────────────┐
│bodyParser()         │
└─────────────────────┘     GET /             ┌─────────────────────┐
           │              ┌──────────────────▶│async(ctx,next) {...}│
           ▼              │                   └─────────────────────┘
┌─────────────────────┐   │ POST /signin      ┌─────────────────────┐
│router.routes()      ├───┼──────────────────▶│async(ctx,next) {...}│
└─────────────────────┘   │                   └─────────────────────┘
                          │ GET /hello/:name  ┌─────────────────────┐
                          └──────────────────▶│async(ctx,next) {...}│
                                              └─────────────────────┘

一个HTTP请求是按顺序由一系列注册到koa的middleware处理的,首先由log函数处理,并通过await next()把请求传递到下一个middleware,紧接着是bodyParser处理,最后是router处理。在router的内部,又会根据注册到router的HTTP方法+Path来决定由哪个async函数处理请求。如果URL没有匹配到,则简单返回404。以上就是整个基于koa的webapp处理流程,非常清晰易懂。

参考源码

url

使用Nunjucks

Nunjucks是什么东东?其实它是一个模板引擎。

那什么是模板引擎?

模板引擎就是基于模板配合数据构造出字符串输出的一个组件。比如下面的函数就是一个模板引擎:

javascript
function examResult (data) {
    return `${data.name}同学一年级期末考试语文${data.chinese}分,数学${data.math}分,位于年级第${data.ranking}名。`
}

如果我们输入数据如下:

javascript
examResult({
    name: '小明',
    chinese: 78,
    math: 87,
    ranking: 999
});

该模板引擎把模板字符串里面对应的变量替换以后,就可以得到以下输出:

plain
小明同学一年级期末考试语文78分,数学87分,位于年级第999名。

模板引擎最常见的输出就是输出网页,也就是HTML文本。当然,也可以输出任意格式的文本,比如Text,XML,Markdown等等。

有同学要问了:既然JavaScript的模板字符串可以实现模板功能,那为什么我们还需要另外的模板引擎?

因为JavaScript的模板字符串必须写在JavaScript代码中,要想写出新浪首页这样复杂的页面,是非常困难的。

输出HTML有几个特别重要的问题需要考虑:

转义

对特殊字符要转义,避免受到XSS攻击。比如,如果变量name的值不是小明,而是小明<script>...</script>,模板引擎输出的HTML到了浏览器,就会自动执行恶意JavaScript代码。

格式化

对不同类型的变量要格式化,比如,货币需要变成$12,345.00这样的格式,日期需要变成2016-01-01这样的格式。

简单逻辑

模板还需要能执行一些简单逻辑,比如,要按条件输出内容,需要if实现如下输出:

plain
{{ name }}同学,
{% if score >= 90 %}
    成绩优秀,应该奖励
{% elif score >=60 %}
    成绩良好,继续努力
{% else %}
    不及格,建议回家打屁股
{% endif %}

所以,我们需要一个功能强大的模板引擎,来完成页面输出的功能。

Nunjucks

我们选择Nunjucks作为模板引擎。Nunjucks是Mozilla开发的一个纯JavaScript编写的模板引擎,既可以用在Node环境下,又可以运行在浏览器端。但是,主要还是运行在Node环境下,因为浏览器端有更好的模板解决方案,例如MVVM框架。

如果你使用过Python的模板引擎jinja2,那么使用Nunjucks就非常简单,两者的语法几乎是一模一样的,因为Nunjucks就是用JavaScript重新实现了jinjia2。

从上面的例子我们可以看到,虽然模板引擎内部可能非常复杂,但是使用一个模板引擎是非常简单的,因为本质上我们只需要构造这样一个函数:

javascript
function render(view, model) {
    // TODO:...
}

其中,view是模板的名称(又称为视图),因为可能存在多个模板,需要选择其中一个。model就是数据,在JavaScript中,它就是一个简单的Object。render函数返回一个字符串,就是模板的输出。

下面我们来使用Nunjucks这个模板引擎来编写几个HTML模板,并且用实际数据来渲染模板并获得最终的HTML输出。

我们创建一个use-nunjucks的VS Code工程结构如下:

use-nunjucks/
├── app.mjs
├── package-lock.json
├── package.json
└── view
    ├── base.html    <-- HTML模板文件
    ├── extend.html  <-- HTML模板文件
    └── hello.html   <-- HTML模板文件

其中,模板文件存放在view目录中。

我们先用npm install nunjucks安装依赖项并在package.json中添加nunjucks的依赖:

javascript
"nunjucks": "^3.2.4"

注意,模板引擎是可以独立使用的,并不需要依赖koa。

紧接着,我们要编写使用Nunjucks的函数render。怎么写?方法是查看Nunjucks的官方文档,仔细阅读后,在app.js中编写代码如下:

javascript
import nunjucks from 'nunjucks';

function createEnv(path, { autoescape = true, noCache = false, watch = false, throwOnUndefined = false }, filters = {}) {
    const loader = new nunjucks.FileSystemLoader(path, {
        noCache: noCache,
        watch: watch
    });
    const env = new nunjucks.Environment(loader, {
        autoescape: autoescape,
        throwOnUndefined: throwOnUndefined
    });
    for (let name in filters) {
        env.addFilter(name, filters[name]);
    }
    return env;
}

const env = createEnv('view', {
    noCache: true
}, {
    hex: function (n) {
        return '0x' + n.toString(16);
    }
});

变量env就表示Nunjucks模板引擎对象,它有一个render(view, model)方法,正好传入viewmodel两个参数,并返回字符串。

创建env需要的参数可以查看文档获知。我们用关键字参数作为默认值,最后使用new nunjucks.FileSystemLoader('view')创建一个文件系统加载器,从view目录读取模板。

我们编写一个hello.html模板文件,放到view目录下,内容如下:

html
<h1>Hello {{ name }}</h1>

然后,我们就可以用下面的代码来渲染这个模板:

javascript
const s = env.render('hello.html', { name: '小明' });
console.log(s);

获得输出如下:

html
<h1>Hello 小明</h1>

咋一看,这和使用JavaScript模板字符串没啥区别嘛。不过,试试:

javascript
const s = env.render('hello.html', { name: '<script>alert("小明")</script>' });
console.log(s);

获得输出如下:

html
<h1>Hello &lt;script&gt;alert("小明")&lt;/script&gt;</h1>

这样就避免了输出恶意脚本。

此外,可以使用Nunjucks提供的功能强大的tag,编写条件判断、循环等功能,例如:

html
<!-- 循环输出名字 -->
<body>
    <h3>Fruits List</h3>
    {% for f in fruits %}
    <p>{{ f }}</p>
    {% endfor %}
</body>

Nunjucks模板引擎最强大的功能在于模板的继承。仔细观察各种网站可以发现,网站的结构实际上是类似的,头部、尾部都是固定格式,只有中间页面部分内容不同。如果每个模板都重复头尾,一旦要修改头部或尾部,那就需要改动所有模板。

更好的方式是使用继承。先定义一个基本的网页框架base.html

html
<html><body>
{% block header %} <h3>Unnamed</h3> {% endblock %}
{% block body %} <div>No body</div> {% endblock %}
{% block footer %} <div>copyright</div> {% endblock %}
</body>

base.html定义了三个可编辑的块,分别命名为headerbodyfooter。子模板可以有选择地对块进行重新定义:

html
{% extends 'base.html' %}

{% block header %}<h1>{{ header }}</h1>{% endblock %}

{% block body %}<p>{{ body }}</p>{% endblock %}

然后,我们对子模板进行渲染:

javascript
console.log(env.render('extend.html', {
    header: 'Hello',
    body: 'bla bla bla...'
}));

输出HTML如下:

html
<html><body>
<h1>Hello</h1>
<p>bla bla bla...</p>
<div>copyright</div> <-- footer没有重定义,所以仍使用父模板的内容
</body>

性能

最后我们要考虑一下Nunjucks的性能。

对于模板渲染本身来说,速度是非常非常快的,因为就是拼字符串嘛,纯CPU操作。

性能问题主要出现在从文件读取模板内容这一步。这是一个IO操作,在Node.js环境中,我们知道,单线程的JavaScript最不能忍受的就是同步IO,但Nunjucks默认就使用同步IO读取模板文件。

好消息是Nunjucks会缓存已读取的文件内容,也就是说,模板文件最多读取一次,就会放在内存中,后面的请求是不会再次读取文件的,只要我们指定了noCache: false这个参数。

在开发环境下,可以关闭cache,这样每次重新加载模板,便于实时修改模板。在生产环境下,一定要打开cache,这样就不会有性能问题。

Nunjucks也提供了异步读取的方式,但是这样写起来很麻烦,有简单的写法我们就不会考虑复杂的写法。保持代码简单是可维护性的关键。

参考源码

use-nunjucks

使用MVC

我们已经可以用koa处理不同的URL,还可以用Nunjucks渲染模板。现在,是时候把这两者结合起来了!

当用户通过浏览器请求一个URL时,koa将调用某个异步函数处理该URL。在这个异步函数内部,我们用一行代码:

javascript
ctx.render('home.html', { name: 'Michael' });

通过Nunjucks把数据用指定的模板渲染成HTML,然后输出给浏览器,用户就可以看到渲染后的页面了:

             ┌─────────────────────────────┐
HTTP Request │GET /Bob                     │
             └─────────────────────────────┘

                            │ name = Bob

             ┌─────────────────────────────┐
     app.mjs │GET /:name                   │
             │async (ctx, next) {          │
             │    ctx.render('home.html', {│
             │        name: ctx.params.name│
             │    });                      │
             │}                            │
             └─────────────────────────────┘

                            │ {{ name }} ─▶ Bob

             ┌─────────────────────────────┐
    Template │<html>                       │
             │<body>                       │
             │    <p>Hello, {{ name }}!</p>│
             │</body>                      │
             │</html>                      │
             └─────────────────────────────┘

                            │ Output

             ┌─────────────────────────────┐
        HTML │<html>                       │
             │<body>                       │
             │    <p>Hello, Bob!</p>       │
             │</body>                      │
             │</html>                      │
             └─────────────────────────────┘

这就是传说中的MVC:Model-View-Controller,中文名“模型-视图-控制器”。

异步函数是C:Controller,Controller负责业务逻辑,比如检查用户名是否存在,取出用户信息等等;

包含变量的模板就是V:View,View负责显示逻辑,通过简单地替换一些变量,View最终输出的就是用户看到的HTML。

MVC中的Model在哪?Model是用来传给View的,这样View在替换变量的时候,就可以从Model中取出相应的数据。

上面的例子中,Model就是一个JavaScript对象:

javascript
{ name: 'Bob' }

下面,我们根据原来的url2-koa创建工程view-koa,把koa2、Nunjucks整合起来,然后,把原来直接输出字符串的方式,改为ctx.render(view, model)的方式。

工程koa-mvc结构如下:

koa-mvc/
├── app.mjs
├── controller
│   ├── index.mjs
│   └── signin.mjs
├── controller.mjs
├── package-lock.json
├── package.json
├── static/  <-- 静态资源文件
│   ├── bootstrap.css
│   └── favicon.ico
├── view/  <-- html模板文件
│   ├── base.html
│   ├── index.html
│   ├── signin-failed.html
│   └── signin-ok.html
└── view.mjs

package.json中,我们将要用到的依赖包有:

text
"@koa/bodyparser": "^5.1.1",
"@koa/router": "^12.0.1",
"koa": "^2.15.3",
"koa-mount": "^4.0.0",
"koa-static": "^5.0.0",
"nunjucks": "^3.2.4"

先用npm install安装依赖包,然后,我们准备编写以下两个Controller:

处理首页 GET /

我们定义一个async函数处理首页URL/

javascript
async function index(ctx, next) {
    ctx.render('index.html', {
        title: 'Welcome'
    });
}

注意到koa并没有在ctx对象上提供render方法,这里我们假设应该这么使用,这样,我们在编写Controller的时候,最后一步调用ctx.render(view, model)就完成了页面输出。

处理登录请求 POST /signin

我们再定义一个async函数处理登录请求/signin

javascript
async function signin(ctx, next) {
    let email = ctx.request.body.email || '';
    let password = ctx.request.body.password || '';
    if (email === 'admin@example.com' && password === '123456') {
        console.log('signin ok!');
        ctx.render('signin-ok.html', {
            title: 'Sign In OK',
            name: 'Mr Bob'
        });
    } else {
        console.log('signin failed!');
        ctx.render('signin-failed.html', {
            title: 'Sign In Failed'
        });
    }
}

由于登录请求是一个POST,我们就用ctx.request.body.<name>拿到POST请求的数据,并给一个默认值。

登录成功时我们用signin-ok.html渲染,登录失败时我们用signin-failed.html渲染,所以,我们一共需要以下3个View:

  • index.html
  • signin-ok.html
  • signin-failed.html

编写View

在编写View的时候,我们实际上是在编写HTML页。为了让页面看起来美观大方,使用一个现成的CSS框架是非常有必要的。我们用Bootstrap这个CSS框架。从首页下载zip包后解压,我们把所有静态资源文件放到/static目录下,这样我们在编写HTML的时候,可以直接用Bootstrap的CSS,像这样:

javascript
<link rel="stylesheet" href="/static/bootstrap.css">

现在,在使用MVC之前,第一个问题来了,如何处理静态文件?

我们把所有静态资源文件全部放入/static目录,目的就是能统一处理静态文件。在koa中,我们需要编写一个middleware,处理以/static/开头的URL。

如果不想自己编写处理静态文件的middleware,可以直接使用koa-mountkoa-static组合来处理静态文件:

javascript
// 处理静态文件:
app.use(mount('/static', serve('static')));

上述代码大致相当于自己手写一个middleware:

javascript
app.use(async (ctx, next) => {
    // 判断是否以指定的url开头:
    if (ctx.request.path.startsWith('/static/')) {
        // 获取文件完整路径:
        let fp = ctx.request.path;
        if (await fs.exists(ctx.request.path)) {
            // 根据扩展名设置mime:
            ctx.response.type = lookupMime(ctx.request.path);
            // 读取文件内容并赋值给response.body:
            ctx.response.body = await fs.readFile(fp);
        } else {
            // 文件不存在:
            ctx.response.status = 404;
        }
    } else {
        // 不是指定前缀的URL,继续处理下一个middleware:
        await next();
    }
});

集成Nunjucks

集成Nunjucks实际上也是编写一个middleware,这个middleware的作用是给ctx对象绑定一个render(view, model)的方法,这样,后面的Controller就可以调用这个方法来渲染模板了。

我们创建一个view.mjs来实现这个middleware:

javascript
import nunjucks from 'nunjucks';

function createEnv(path, { autoescape = true, noCache = false, watch = false, throwOnUndefined = false }, filters = {}) {
    ...
    return env;
}

const env = createEnv('view', {
    noCache: process.env.NODE_ENV !== 'production'
});

// 导出env对象:
export default env;

使用的时候,我们在app.mjs添加如下代码:

javascript
import templateEngine from './view.mjs';

// app.context是每个请求创建的ctx的原型,
// 因此把render()方法绑定在原型对象上:
app.context.render = function (view, model) {
    this.response.type = 'text/html; charset=utf-8';
    this.response.body = templateEngine.render(view, Object.assign({}, this.state || {}, model || {}));
};

注意到createEnv()函数和前面使用Nunjucks时编写的函数是一模一样的。

这里我们判断当前环境是否是production环境。如果是,就使用缓存,如果不是,就关闭缓存。在开发环境下,关闭缓存后,我们修改View,可以直接刷新浏览器看到效果,否则,每次修改都必须重启Node程序,会极大地降低开发效率。

Node.js在全局变量process中定义了一个环境变量env.NODE_ENV,为什么要使用该环境变量?因为我们在开发的时候,环境变量应该设置为'development',而部署到服务器时,环境变量应该设置为'production'。在编写代码的时候,要根据当前环境作不同的判断。

注意:生产环境上必须配置环境变量NODE_ENV = 'production',而开发环境不需要配置,实际上NODE_ENV可能是undefined,所以判断的时候,不要用NODE_ENV === 'development'

类似的,我们在使用上面编写的处理静态文件的middleware时,也可以根据环境变量判断:

javascript
if (!isProduction) {
    app.use(mount('/static', serve('static')));
}

这是因为在生产环境下,静态文件是由部署在最前面的反向代理服务器(如Nginx)处理的,Node程序不需要处理静态文件。而在开发环境下,我们希望koa能顺带处理静态文件,否则,就必须手动配置一个反向代理服务器,这样会导致开发环境非常复杂。

编写View

在编写View的时候,非常有必要先编写一个base.html作为骨架,其他模板都继承自base.html,这样,才能大大减少重复工作。

编写HTML不在本教程的讨论范围之内。这里我们参考Bootstrap的官网简单编写了base.html

运行

一切顺利的话,这个koa-mvc工程应该可以顺利运行。运行前,我们再检查一下app.mjs里的middleware的顺序:

第一个middleware是记录URL以及页面执行时间:

javascript
app.use(async (ctx, next) => {
    console.log(`Process ${ctx.request.method} ${ctx.request.url}...`);
    const start = Date.now();
    await next();
    const execTime = Date.now() - start;
    ctx.response.set('X-Response-Time', `${execTime}ms`);
});

第二个middleware处理静态文件:

javascript
if (!isProduction) {
    app.use(mount('/static', serve('static')));
}

第三个middleware解析POST请求:

javascript
app.use(bodyParser());

最后一个middleware处理URL路由:

javascript
app.use(await controller());

现在,用node app.mjs运行代码,不出意外的话,在浏览器输入localhost:3000/,可以看到首页内容:

koa-index

直接在首页登录,如果输入正确的Email和Password,进入登录成功的页面:

koa-signin-ok

如果输入的Email和Password不正确,进入登录失败的页面:

koa-signin-failed

怎么判断正确的Email和Password?目前我们在signin.js中是这么判断的:

javascript
if (email === 'admin@example.com' && password === '123456') {
    ...
}

当然,真实的网站会根据用户输入的Email和Password去数据库查询并判断登录是否成功,不过这需要涉及到Node.js环境如何操作数据库,我们后面再讨论。

如果要以production模式启动app,需要设置环境变量,可以通过以下命令启动:

plain
$ NODE_ENV=production node app.mjs

这样模板缓存将生效,同时不再响应静态文件请求。

扩展

注意到ctx.render内部渲染模板时,Model对象并不是传入的model变量,而是:

javascript
Object.assign({}, ctx.state || {}, model || {})

这个小技巧是为了扩展。

首先,model || {}确保了即使传入undefined,model也会变为默认值{}Object.assign()会把除第一个参数外的其他参数的所有属性复制到第一个参数中。第二个参数是ctx.state || {},这个目的是为了能把一些公共的变量放入ctx.state并传给View。

例如,某个middleware负责检查用户权限,它可以把当前用户放入ctx.state中:

javascript
app.use(async (ctx, next) => {
    var user = tryGetUserFromCookie(ctx.request);
    if (user) {
        ctx.state.user = user;
        await next();
    } else {
        ctx.response.status = 403;
    }
});

这样就没有必要在每个Controller的async函数中都把user变量放入model中。

参考源码

koa-mvc

使用REST

自从Roy Fielding博士在2000年他的博士论文中提出REST(Representational State Transfer)风格的软件架构模式后,REST就基本上迅速取代了复杂而笨重的SOAP,成为Web API的标准了。

什么是Web API呢?

如果我们想要获取某个电商网站的某个商品,输入http://localhost:3000/products/123,就可以看到id为123的商品页面,但这个结果是HTML页面,它同时混合包含了Product的数据和Product的展示两个部分。对于用户来说,阅读起来没有问题,但是,如果机器读取,就很难从HTML中解析出Product的数据。

如果一个URL返回的不是HTML,而是机器能直接解析的数据,这个URL就可以看成是一个Web API。比如,读取http://localhost:3000/api/products/123,如果能直接返回Product的数据,那么机器就可以直接读取。

REST就是一种设计API的模式。最常用的数据格式是JSON。由于JSON能直接被JavaScript读取,所以,以JSON格式编写的REST风格的API具有简单、易读、易用的特点。

编写API有什么好处呢?由于API就是把Web App的功能全部封装了,所以,通过API操作数据,可以极大地把前端和后端的代码隔离,使得后端代码易于测试,前端代码编写更简单。

此外,如果我们把前端页面看作是一种用于展示的客户端,那么API就是为客户端提供数据、操作数据的接口。这种设计可以获得极高的扩展性。例如,当用户需要在手机上购买商品时,只需要开发针对iOS和Android的两个客户端,通过客户端访问API,就可以完成通过浏览器页面提供的功能,而后端代码基本无需改动。

当一个Web应用以API的形式对外提供功能时,整个应用的结构就扩展为:

REST-arch

把网页视为一种客户端,是REST架构可扩展的一个关键。

REST API规范

编写REST API,实际上就是编写处理HTTP请求的async函数,不过,REST请求和普通的HTTP请求有几个特殊的地方:

  1. REST请求仍然是标准的HTTP请求,但是,除了GET请求外,POST、PUT等请求的body是JSON数据格式,请求的Content-Typeapplication/json
  2. REST响应返回的结果是JSON数据格式,因此,响应的Content-Type也是application/json

REST规范定义了资源的通用访问格式,虽然它不是一个强制要求,但遵守该规范可以让人易于理解。

例如,商品Product就是一种资源。获取所有Product的URL如下:

plain
GET /api/products

而获取某个指定的Product,例如,id为123的Product,其URL如下:

plain
GET /api/products/123

新建一个Product使用POST请求,JSON数据包含在body中,URL如下:

plain
POST /api/products

更新一个Product使用PUT请求,例如,更新id为123的Product,其URL如下:

plain
PUT /api/products/123

删除一个Product使用DELETE请求,例如,删除id为123的Product,其URL如下:

plain
DELETE /api/products/123

资源还可以按层次组织。例如,获取某个Product的所有评论,使用:

plain
GET /api/products/123/reviews

当我们只需要获取部分数据时,可通过参数限制返回的结果集,例如,返回第2页评论,每页10项,按时间排序:

plain
GET /api/products/123/reviews?page=2&size=10&sort=time

koa处理REST

既然我们已经使用koa作为Web框架处理HTTP请求,因此,我们仍然可以在koa中响应并处理REST请求。

我们复制上一节的工程,重命名为koa-rest,然后准备添加REST API。

对于controller来说,我们只要返回如下内容即可视为一个REST API:

javascript
ctx.body =  {
    id: 12345,
    name: 'Bob',
    description: 'A rest api'
};

koa检测到ctx.body的赋值是一个JavaScript对象时,自动把这个Object变成JSON字符串输出,无需任何额外配置和代码。

提示

ctx.body是ctx.response.body的引用,两者是等同的。

我们给signin.mjs添加两个REST API:

  • GET /api/users/:id:根据id获取用户信息;
  • POST /api/signin:发送一个POST请求,并返回登录结果。

获取用户信息的async函数如下:

javascript
// /api/users/:id
async function user_info(ctx, next) {
    let id = ctx.params.id;
    if (id === '12345') {
        ctx.body = {
            id: 12345,
            email: 'admin@example.com',
            name: 'Bob'
        };
    } else {
        ctx.body = {
            error: 'USER_NOT_FOUND'
        };
    }
}

处理登录请求的async函数如下:

javascript
async function signin(ctx, next) {
    let email = ctx.request.body.email || '';
    let password = ctx.request.body.password || '';
    if (email === 'admin@example.com' && password === '123456') {
        console.log('signin ok!');
        ctx.body = {
            id: 12345,
            email: email,
            name: 'Bob'
        };
    } else {
        console.log('signin failed!');
        ctx.body = {
            error: 'SIGNIN_FAILED'
        };
    }
}

当发生错误时,返回的信息包含error字段,客户端依靠该字段来判断是否出错。

最后导出URL处理函数:

javascript
export default {
    'POST /api/signin': signin,
    'GET /api/users/:id': user_info
};

现在,我们可以直接在浏览器测试GET请求:

GET OK

输入无效的ID,返回错误:

GET ERROR

对于POST请求,我们无法直接在浏览器测试,可以用curl命令测试如下:

plain
$ curl -H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"123456"}' \
http://localhost:3000/api/signin
{
  "id": 12345,
  "email": "admin@example.com",
  "name": "Bob"
}

输入错误的口令,返回错误信息:

plain
$ curl -H 'Content-Type: application/json' \
-d '{"email":"admin@example.com","password":"invalid"}' \
http://localhost:3000/api/signin
{
  "error": "SIGNIN_FAILED"
}

由于我们把登录从传统的POST表单改成了REST,因此,前端页面需要编写JavaScript代码来发送REST请求,修改HTML如下:

html
<!-- 给form加上onsubmit回调函数 -->
<form id="signin-form" onsubmit="return signin()">
    ...
</form>

用JavaScript发送REST请求如下:

javascript
function signin() {
    // 获取表单输入:
    let form = document.querySelector('#signin-form');
    let email = form.querySelector('input[name=email]').value;
    let password = form.querySelector('input[name=password]').value;
    // REST请求的数据:
    let data = {
        email: email,
        password: password
    };
    // 发送请求:
    fetch('/api/signin', {
        // 以POST方式发送:
        method: 'POST',
        headers: {
            // Content-Type设置为JSON:
            'Content-Type': 'application/json'
        },
        // 发送数据序列化为JSON:
        body: JSON.stringify(data)
    }).then(resp => {
        // 收到响应后解析JSON数据:
        resp.json().then(result => {
            // 解析后的数据:
            console.log(result);
            // 判断是否有error字段:
            if (result.error) {
                alert(`Sign in failed: ${result.error}`);
            } else {
                // 登录成功,取出name字段:
                alert(`Welcome, ${result.name}!`);
            }
        });
    });
    // 必须返回false以取消浏览器自动提交表单:
    return false;
}

可见,在koa中处理REST请求是非常简单的。bodyParser()这个middleware可以解析请求的JSON数据并绑定到ctx.request.body上,输出JSON时我们把JavaScript对象赋值给ctx.response.body就完成了REST请求的处理。

参考源码

koa-rest

数据库

程序运行的时候,数据都是在内存中的。当程序终止的时候,通常都需要将数据保存到磁盘上,无论是保存到本地磁盘,还是通过网络保存到服务器上,最终都会将数据写入磁盘文件。

而如何定义数据的存储格式就是一个大问题。如果我们自己来定义存储格式,比如保存一个班级所有学生的成绩单:

名字成绩
Michael99
Bob85
Bart59
Lisa87

我们可以用一个文本文件保存,一行保存一个学生,用,隔开:

csv
Michael,99
Bob,85
Bart,59
Lisa,87

还可以用JSON格式保存,也是文本文件:

json
[
    {"name":"Michael","score":99},
    {"name":"Bob","score":85},
    {"name":"Bart","score":59},
    {"name":"Lisa","score":87}
]

还可以定义各种保存格式,但是问题来了:

存储和读取需要自己实现,JSON还是标准,自己定义的格式就各式各样了;

不能做快速查询,只有把数据全部读到内存中才能自己遍历,但有时候数据的大小远远超过了内存,根本无法全部读入内存。

为了便于程序保存和读取数据,而且,能直接通过条件快速查询到指定的数据,就出现了数据库(Database)这种专门用于集中存储和查询的软件。

数据库软件诞生的历史非常久远,早在1950年数据库就诞生了。经历了网状数据库,层次数据库,我们现在广泛使用的关系数据库是20世纪70年代基于关系模型的基础上诞生的。

关系模型有一套复杂的数学理论,但是从概念上是十分容易理解的。举个学校的例子:

假设某个XX省YY市ZZ县第一实验小学有3个年级,要表示出这3个年级,可以在Excel中用一个表格画出来:

grade

每个年级又有若干个班级,要把所有班级表示出来,可以在Excel中再画一个表格:

class

这两个表格有个映射关系,就是根据Grade_ID可以在班级表中查找到对应的所有班级:

grade-classes

也就是Grade表的每一行对应Class表的多行,在关系数据库中,这种基于表(Table)的一对多的关系就是关系数据库的基础。

根据某个年级的ID就可以查找所有班级的行,这种查询语句在关系数据库中称为SQL语句,可以写成:

sql
SELECT * FROM classes WHERE grade_id = '1';

结果也是一个表:

grade_idclass_idname
111一年级一班
112一年级二班
113一年级三班

类似的,Class表的一行记录又可以关联到Student表的多行记录:

class-students

由于本教程不涉及到关系数据库的详细内容,如果你想从零学习关系数据库和基本的SQL语句,请参考SQL教程

NoSQL

你也许还听说过NoSQL数据库,很多NoSQL宣传其速度和规模远远超过关系数据库,所以很多同学觉得有了NoSQL是否就不需要SQL了呢?千万不要被他们忽悠了,连SQL都不明白怎么可能搞明白NoSQL呢?

数据库类别

既然我们要使用关系数据库,就必须选择一个关系数据库。目前广泛使用的关系数据库也就这么几种:

付费的商用数据库:

  • Oracle,典型的高富帅;
  • SQL Server,微软自家产品,Windows定制专款;
  • DB2,IBM的产品,听起来挺高端;
  • Sybase,曾经跟微软是好基友,后来关系破裂,现在家境惨淡。

这些数据库都是不开源而且付费的,最大的好处是花了钱出了问题可以找厂家解决,不过在Web的世界里,常常需要部署成千上万的数据库服务器,当然不能把大把大把的银子扔给厂家,所以,无论是Google、Facebook,还是国内的BAT,无一例外都选择了免费的开源数据库:

  • MySQL,大家都在用,一般错不了;
  • PostgreSQL,学术气息有点重,其实挺不错,但知名度没有MySQL高;
  • SQLite,嵌入式数据库,适合桌面和移动应用。

作为一个JavaScript全栈工程师,选择哪个免费数据库呢?这里我们用SQLite,作为嵌入式数据库,优点是不用安装任何软件,直接能用。当然,在生产环境下,应当选择MySQL或者PostgreSQL。

操作数据库

在Node.js中,访问不同的数据库需要安装不同的数据库驱动。

因为我们使用Sqlite,所以需要安装Sqlite的驱动。这里我们选择sqlite3这个驱动,它内置sqlite。

sqlite3通过如下代码可以创建一个db对象:

javascript
// 指定模式打开test.db:
const db = new sqlite3.Database('test.db', sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE | sqlite3.OPEN_FULLMUTEX);

我们传入了OPEN_CREATE参数,表示如果数据库不存在则自动创建,在开发模式下非常方便。

sqlite3使用回调模式执行查询和更新操作,代码如下:

javascript
// query:
db.all('SELECT * FROM users WHERE id=?', [1], function (err, rows) {
});
// update:
db.run('UPDATE users SET name=? WHERE id=?', ['Bob', 1], function (err) {
});

回调模式写起来非常别扭,由于sqlite3没有提供Promise接口,因此无法使用await调用,怎么办?

答案是我们自己封装一个Promise调用,以便通过await来实现异步查询和更新:

javascript
// db.mjs:
import sqlite3 from 'sqlite3';

export function createDatabase(file) {
    const db = new sqlite3.Database(file, sqlite3.OPEN_READWRITE | sqlite3.OPEN_CREATE | sqlite3.OPEN_FULLMUTEX);
    const wrapper = {
        db: db
    };
    // 执行update:
    wrapper.update = async function (strs, ...params) {
        return new Promise((resolve, reject) => {
            let sql = strs.join('?');
            db.run(sql, ...params, function (err) {
                if (err) {
                    reject(err);
                } else {
                    resolve(this.changes);
                }
            });
        });
    };
    // 执行insert并返回lastID:
    wrapper.insert = async function (strs, ...params) {
        return new Promise((resolve, reject) => {
            let sql = strs.join('?');
            db.run(sql, ...params, function (err) {
                if (err) {
                    reject(err);
                } else {
                    resolve(this.lastID);
                }
            });
        });
    };
    // 查询数据,返回array:
    wrapper.select = async function (strs, ...params) {
        return new Promise((resolve, reject) => {
            let sql = strs.join('?');
            if (debug) {
                console.log(`sql = ${sql}, params = [${params.join(', ')}]`);
            }
            db.all(sql, ...params, function (err, rows) {
                if (err) {
                    reject(err);
                } else {
                    resolve(rows);
                }
            });
        });
    };
    // 查询一行数据,不存在返回null:
    wrapper.fetch = async function (strs, ...params) {
        ...
    };
    return wrapper;
}

我们复制前面的koa-mvc工程,命名为sql,准备用实际数据库替换写死的登录逻辑。工程结构如下:

sql/
├── app.mjs
├── db.mjs
└── ...

通过npm install sqlite3安装依赖项并添加依赖:

json
"sqlite3": "^5.1.7"

增加了db.mjs,实现了对sqlite数据库的操作。

我们在app.mjs中初始化一个db对象并绑定到app.context中:

javascript
import { createDatabase } from './db.mjs';

async function initDb() {
    const email = 'admin@example.com';
    const name = 'Bob';
    const password = '123456';
    // 创建db对象:
    const db = createDatabase('test.db');
    // 如果users表不存在则创建表:
    await db.update`CREATE TABLE IF NOT EXISTS users(id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, name TEXT NOT NULL, password TEXT NOT NULL)`;
    // 查询admin用户:
    let user = await db.fetch`SELECT * FROM users WHERE email=${email}`;
    // 用户不存在则自动创建:
    if (user === null) {
        await db.insert`INSERT INTO users (email, name, password) VALUES (${email}, ${name}, ${password})`;
    }
    return db;
}

// 绑定db到app.context:
app.context.db = await initDb();

注意到initDb()中自动创建表和用户的代码都是为了便于开发。

有了数据库支持,我们就可以把signin.mjs写死的代码替换为查询数据库用户:

javascript
// signin:
async function signin(ctx, next) {
    let email = ctx.request.body.email || '';
    let password = ctx.request.body.password || '';
    // 从数据库查询用户:
    let user = await ctx.db.fetch`SELECT * FROM users WHERE email=${email}`;
    if (user !== null && user.password === password) {
        console.log('signin ok!');
        ctx.render('signin-ok.html', {
            title: 'Sign In OK',
            name: user.name
        });
    } else {
        console.log('signin failed!');
        ctx.render('signin-failed.html', {
            title: 'Sign In Failed'
        });
    }
}

观察上述代码,我们查询数据库中某个用户的代码如下:

javascript
let user = await ctx.db.fetch`SELECT * FROM users WHERE email=${email}`;

这是一个标签函数,它自动将参数变为如下调用:

javascript
let user = await ctx.db.fetch(['SELECT * FROM users WHERE email=', ''], email);

在函数内部,实际执行的SQL是SELECT * FROM users WHERE email=?,因此,通过标签函数,我们总是以参数化形式执行SQL,避免了SQL注入。

执行node app.mjs,可以看到页面效果,同时,后台会打印出执行的SQL语句与绑定的参数。

参考

参考源码:sql

sqlite数据库:sqlite

sqlite3文档:sqlite3

使用ORM

直接使用sqlite3提供的接口,我们执行数据库操作时必须提供SQL语句,比较底层。

考虑到数据库表是一个二维表,包含多行多列,例如一个users的表:

idemailnamepassword
1admin@example.comBob123456
2lucy@example.comLucyabcdef
3alice@example.comAlicehello123

每一行可以用一个JavaScript对象表示,例如第一行:

javascript
{
    id: 1,
    email: 'admin@example.com',
    name: 'Bob',
    password: '123456'
}

这就是传说中的ORM技术:Object-Relational Mapping,把关系数据库的表结构映射到对象上。是不是很简单?

但是由谁来做这个转换呢?所以ORM框架应运而生。

我们选择Node的ORM框架Sequelize来操作数据库。这样,我们读写的都是JavaScript对象,Sequelize帮我们把对象变成数据库中的行。

用Sequelize查询users表,代码像这样:

javascript
let users = await User.findAll();

根据email查询一个用户,代码像这样:

javascript
let user = await User.find({
    where: {
        email: 'admin@example.com'
    }
});

Sequelize的所有操作都是Promise,所以我们可以用await实现异步调用。

实战

在使用Sequelize操作数据库之前,我们需要告诉Sequelize如何映射数据库中的每一个表。

users表为例,我们需要定义如下:

javascript
// orm.mjs:
import { Sequelize, DataTypes } from 'sequelize';

// 创建sequelize对象表示已连接到数据库:
export const sequelize = new Sequelize('sqlite:test.db');

// 定义User:
export const User = sequelize.define('User', {
    // 每一列的定义:
    id: {
        primaryKey: true,
        autoIncrement: true,
        type: DataTypes.INTEGER,
        allowNull: false
    },
    email: {
        unique: true,
        type: DataTypes.STRING,
        allowNull: false
    },
    name: {
        type: DataTypes.STRING,
        allowNull: false
    },
    password: {
        type: DataTypes.STRING,
        allowNull: false
    }
}, {
    // 指定表名:
    tableName: 'users'
});

在定义列的时候,主键以primaryKey: true指定,具有唯一约束的列用unique: true表示,数据类型用DataTypes表示。

这样Sequelize就有了足够的信息来实现ORM。最后将sequelizeUser对象导出。

我们根据上一节的sql工程结构创建orm工程,结构如下:

sql/
├── app.mjs
├── orm.mjs
└── ...

删除了db.mjs,增加orm.mjs

然后,通过npm install sequelize sqlite3安装依赖项并添加依赖:

json
"sequelize": "^6.37.3",
"sqlite3": "^5.1.7"

app.mjs中,删除相关SQL操作,改为通过Sequelize初始化数据库:

javascript
import { sequelize, User } from './orm.mjs';

...

async function initDb() {
    // 自动创建数据库表, 仅限开发模式:
    await sequelize.sync();
    // 查询admin用户:
    const email = 'admin@example.com';
    let user = await User.findOne({
        where: {
            email: email
        }
    });
    // 不存在则自动创建:
    if (user === null) {
        await User.create({
            email: email,
            name: 'Bob',
            password: '123456'
        });
    }
}
await initDb();

...

使用Sequelize时,无需绑定app.context,因为我们主要通过具体的Model比如User来操作数据库。修改signin.mjs如下:

javascript
import { User } from '../orm.mjs';

// signin:
async function signin(ctx, next) {
    let email = ctx.request.body.email || '';
    let password = ctx.request.body.password || '';
    // 调用Model.findOne()查询一行记录:
    let user = await User.findOne({
        where: {
            email: email
        }
    });
    if (user !== null && user.password === password) {
        console.log('signin ok!');
        ctx.render('signin-ok.html', {
            title: 'Sign In OK',
            name: user.name
        });
    } else {
        console.log('signin failed!');
        ctx.render('signin-failed.html', {
            title: 'Sign In Failed'
        });
    }
}

运行node app.mjs启动服务器,可以观察到Sequelize访问数据库时打印的SQL语句。这里需要注意的是,Sequelize会为每个Model自动添加一个createdAtupdatedAt字段,用来记录创建和更新时间。因此,创建的users表的SQL语句为:

sql
CREATE TABLE IF NOT EXISTS `users` (
    `id` INTEGER PRIMARY KEY AUTOINCREMENT,
    `email` VARCHAR(255) NOT NULL UNIQUE,
    `name` VARCHAR(255) NOT NULL,
    `password` VARCHAR(255) NOT NULL,
    `createdAt` DATETIME NOT NULL,
    `updatedAt` DATETIME NOT NULL
);

常用操作

Sequelize提供了findAll()findOne()两种查询,分别返回多行和一行。

create()操作可以存储一个对象到数据库的一行记录,save()destroy()分别对应更新和删除操作。

Sequelize还提供了一对多等高级ORM功能,具体可以参考官方文档。

参考

参考源码:orm

Sequelize:官方网站

操作数据库