TypeScript 高级教程(四)
七、在服务器上运行 TypeScript
如果你觉得 Node 通过一次只运行一段代码来实现并行很奇怪,那是因为它确实如此。这是我称之为落后主义的一个例子。—吉姆·r·威尔逊
在服务器上运行 JavaScript 并不是一个新概念——Netscape Enterprise Server 早在 1994 年就提供了这个特性。目前有大量 JavaScript 的服务器端实现运行在超过六种不同的脚本引擎上。除了这些纯 JavaScript 实现之外,JavaScript 还可以在任何拥有脚本主机的平台上运行。
尽管 JavaScript 语言对于所有这些实现都是通用的,但每一种实现都将提供不同的 API、框架、模块或基本类库来执行基于浏览器的 JavaScript 程序中通常不可用的操作。服务器端实现中可用模块的范围至关重要,这就是 Node 如此成功的原因(也是它被选入本章的原因)。
Node 不仅有超过 475,000 个可用的模块,从简单的助手到整个数据库服务器,还可以通过一个简单的命令将它们添加到您的程序中,这要归功于 Node Package Manager (NPM)。这意味着您可以简单地通过在命令窗口中键入npm install MongoDB来添加一个数据库模块,比如 MongoDB。Node 是跨平台的,提供 Windows、Mac OSX、Linux、Docker 和 SunOS 的安装程序。
为了演示如何在 TypeScript 中使用 Node,本章逐步将一个简单的应用发展成一个使用多个模块的应用。这演示了代码以及添加包和类型定义的过程。虽然示例显示了来自 Visual Studio 和 Windows 命令提示符的屏幕截图,但您可以轻松地将您所学到的一切转移到其他开发工具和操作系统,例如,OSX 或 WebStorm 上的 Sublime Text 2 和 terminal 以及 Linux 上的 Terminal。如果您希望在不同的机器上获得类似的体验,组合是多种多样的,几个集成开发环境是跨平台的(Visual Studio Code、Cloud9、Eclipse、Sublime Text 2、Vim 和 WebStorm 都运行在 Windows、OSX 和 Linux 上)。
安装节点
您可以从 NodeJS 网站下载适用于您所选平台的安装程序
https://nodejs.org/en/download/
创建新项目
示例程序将从一个完全空的项目开始。图 7-1 显示了示例项目和解决方案的开始状态,其中包含一个空的server.ts文件。
如果您使用的是 Visual Studio 2017,可以通过使用 TypeScript 语言创建一个新的空白 Node.js 应用来复制它。如果您正在使用不同的开发环境,您可以简单地启动一个新的项目或文件夹并添加一个空的server.ts文件。
图 7-1。
Empty TypeScript project
要对节点进行自动完成和类型检查,您需要一个描述标准节点 API 的类型定义。最快的方法是与 NPM 合作。
新公共管理理论
您可以使用 NPM 来处理生产和开发依赖性。生产依赖项是应用运行时必须存在的模块。开发依赖项包括工具和类型定义,它们只在编码时需要,在运行应用时不需要。
在添加包之前,您需要一个名为 package.json 的特殊文件,如清单 7-1 所示。该文件描述了您的程序,包括它所拥有的所有依赖项。每当您添加生产或开发依赖项时,该文件都会更新。
{
"name": "NodeApp"
}
Listing 7-1.The empty package.json file
Note
这还不是一个有效的 package.json 文件,但是有效的版本将在本章的稍后部分显示。这个超级简洁的版本将使 NPM 的例子更容易理解。
一旦有了 package.json 文件,添加依赖项唯一需要知道的就是它的名称。对于下面所有的例子,我们都使用 Express,这是一个轻量级的节点 web 框架。
安装包的第一个机制是 Visual Studio 包搜索,通过右键单击解决方案资源管理器的“npm”分支可以找到它。package explorer UI 的左侧允许您搜索软件包,并且您可以使用右侧底部的表单安装选定的软件包。标准选项会将最新版本的包添加到项目中。
图 7-2。
Install ing NPM packages via Visual Studio
您可以通过运行清单 7-2 中所示的命令来获得相同的结果。这恰好是 UI 发出的原始命令。NPM 和 NodeJS 都很容易从您喜欢的命令窗口中使用。
npm install express --save
Listing 7-2.Installing NPM packages via a command
无论您使用 UI 还是直接键入命令,install 命令中的 save 标志都会导致 package.json 文件被更新以显示依赖关系,如清单 7-3 所示。
{
"name": "NodeApp",
"dependencies": {
"express": "⁴.15.4"
}
}
Listing 7-3.
Updated package.json
这就引出了添加包的第三种方法,即直接更新 package.json 文件。如果您在 Visual Studio 中这样做,您将获得包名和版本号的自动完成;这在图 7-3 中有所展示。
图 7-3。
Installing NPM packages via package.json
对于类型定义,只有在开发时才需要依赖项,而在生产中不需要。您可以通过在 package explorer UI 中选择“development”依赖类型,或者在运行 install 命令时使用 save 标志的--save-dev变体,将一个包标记为开发依赖。当通过 package.json 文件添加开发依赖时,它应该进入单独的devDependencies字典。
{
"name": "NodeApp",
"dependencies": {
"express": "⁴.15.4"
},
"devDependencies": {
"@types/node": "⁸.0.26"
}
}
Listing 7-4.Updated package.json with node type definition
现在你是一名 NPM 专家,你将能够添加创建一个工作节点 web 应用所需的任何依赖项,这是本章的主题。
简单节点程序
既然项目已经建立,就可以使用一个运行 web 服务器并响应请求的简单程序来演示 Node 了。HTTP 服务器只是将所有请求传递给您提供的回调函数。没有内置的工具来处理不同的请求或路由,或者帮助格式化响应(如果您需要这些,它们在中间件中是可用的,比如 Express,这将在本章后面介绍)。
清单 7-5 显示了完整的程序,它创建了一个监听端口 8080 的 http 服务器。所有请求都被传递给requestListener函数,该函数对所有请求给出标准的文本响应。requestListener函数被传递给代表request和response的两个参数。可以从request参数中获得信息,比如方法、请求头和请求体。您可以在response参数的头和体中添加内容。你必须通过调用response.end()来表明你已经完成;否则,HTTP 服务器不向客户端发送响应,让客户端等待响应,直到超时。
引用注释用于指示节点类型定义的位置。这允许import语句引用http模块,该模块在项目中不作为外部模块出现——http模块将在运行时由 Node 提供。
/// <reference path="./node_modules/@types/node/index.d.ts" />
import * as http from 'http';
const portNumber = 8080;
function requestListener(request: http.ServerRequest, response: http.ServerResponse) {
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.write('Response Text Here');
response.end();
}
http.createServer(requestListener).listen(portNumber);
console.log('Listening on localhost:' + portNumber);
Listing 7-5.A simple Node server
如果您将这个示例代码放在一个名为 app.ts 的文件中,那么您可以通过从包含源代码的文件夹中运行命令node app.js来运行这个清单中的 http 服务器(注意,您在这个命令中传递的是编译后的 JavaScript 文件,而不是 TypeScript 文件)。如图 7-4 所示,你会在命令窗口中看到消息Listening on localhost:8080。只要命令窗口保持打开,服务器就会运行。
图 7-4。
Running the program
要向服务器发出请求,请打开 web 浏览器并在地址栏中输入localhost:8080。您应该会收到如图 7-5 所示的Response Text Here消息。因为所有对服务器的请求都被发送到同一个requestListener方法,所以您可以在localhost:8080输入任何地址并接收相同的消息,例如localhost:8080/Some/Path/Here/或localhost:8080/?some=query&string=here。
图 7-5。
Calling the program from a browser
请求信息
能够如此容易地启动 web 服务器是很棒的,但是几乎可以肯定的是,您将希望从请求中获得信息,以提供与所请求的信息相匹配的响应。清单 7-6 展示了如何获取请求方法和关于所请求 URL 的信息,这些信息可用于路由请求或获取用于查找与请求匹配的数据的数据。
/// <reference path="./node_modules/@types/node/index.d.ts" />
import * as http from 'http';
const portNumber = 8080;
function requestListener(request: http.ServerRequest, response: http.ServerResponse) {
response.writeHead(200, { 'Content-Type': 'text/plain' });
response.write('Method: ' + request.method + '\n');
response.write('Url: ' + request.url + '\n');
response.write('Response Text Here');
response.end();
}
http.createServer(requestListener).listen(portNumber);
console.log('Listening on localhost:' + portNumber);
Listing 7-6.Getting more information from the request
在本例中,从请求中获得的信息被简单地附加到响应中,以便在发出请求时在浏览器中显示这些信息。在地址栏输入http://localhost:8080/Customers/Smith/John后,响应如图 7-6 所示。您可以使用请求的属性来决定如何处理请求。
图 7-6。
Displaying information about the request
虽然您可以使用这些信息编写自己的框架来处理对节点服务器的请求,但是路由请求和从请求中获取信息的工作已经很好地完成了,并且可以作为一个模块与 NPM 一起安装。除非您想使用请求信息做一些不寻常的事情,否则您可能会发现,使用一个为您的程序提供框架的现有模块将会节省时间和精力,并涵盖您没有计划的场景。
下一节描述如何使用 Express 模块构建应用,Express 模块是一个轻量级的 web 框架,用于 Node,它不指定授权、持久性或模板的细节。
使用 Express 编写应用
在 Node 中处理原始请求和响应允许访问 HTTP 通信的底层细节;但大多数情况下,你不会有兴趣自己处理所有的细节。Express 模块提供了一个轻量级框架,允许您将精力集中在应用上,而不是路由和 HTTP 通信上。Express 既是一种快速入门的方式,也是一个将您的程序整合在一起的健壮框架。
要使用 Express,您需要 Express 依赖项和 Express 类型定义开发依赖项。应用的 package.json 文件的更完整版本如清单 7-7 所示,其中包括这两个依赖项。
{
"name": "node-app",
"version": "0.0.1",
"description": "NodeApp",
"main": "server.js",
"author": {
"name": "Steve Fenton"
},
"dependencies": {
"express": "4.15.4"
},
"devDependencies": {
"@types/node": "8.0.26",
"@types/express": "4.0.37"
}
}
Listing 7-7.Example package.json
package.json 文件指出,我们程序的入口点将是名为 server.js 的文件,它包含 express 依赖项,以及来自@types 组织的节点和 Express 类型定义。
简单快速程序
清单 7-8 是基于 Express 的简单节点程序的更新版本,而不是基于http模块。虽然一般的模式是相似的,但是requestListener是专门添加到应用根地址的 HTTP GET 方法中的。
这意味着只有对http://localhost:8080/的请求才会调用requestListener函数。与前面的例子不同,不匹配路由的请求将会失败,例如,对http://localhost:8080/ Customers的请求将会收到带有消息“Cannot GET /Customers/Smith/John”的 404 响应。
import * as express from 'express';
const portNumber = 8080;
const app = express();
app.get('/', (request, response) => {
response.send('You requested ' + request.query.firstname + ' ' + request.query.lastname);
})
app.listen(portNumber, 'localhost', () => {
console.log('Listening on localhost:' + portNumber);
});
Listing 7-8.Using Express
这个例子的正确请求应该包括firstName和lastName查询字符串参数。Express 将查询字符串映射到request.query属性。http://localhost:8080/?firstname=John&lastname=Smith的完整示例请求地址将导致响应中返回消息You requested John Smith,如图 7-7 所示。
图 7-7。
Calling the Ex press program
多条路线
您可以在您的程序中为不同的路线提供不同的处理程序,例如,清单 7-9 中的代码为http://localhost:8080/ One/和http://localhost:8080/Two/分别提供了一个处理程序。Express 为您处理所有路由,并确保正确的功能处理每个请求。
import * as express from 'express';
const portNumber = 8080;
const app = express();
app.get('/', (request, response) => {
response.send('You requested ' + request.query.firstname + ' ' + request.query.lastname);
})
app.get('/One/', (request, response) => {
response.send('You got handler One');
});
app.get('/Two/', (request, response) => {
response.send('You got handler Two');
});
app.listen(portNumber, 'localhost', () => {
console.log('Listening on localhost:' + portNumber);
});
Listing 7-9.Using multiple routes
您可以像以前一样在浏览器中测试这些路由,并获得适当的响应
http://localhost:8080/One/- >“你得到了一号处理者”http://localhost:8080/Two/- >“你有二号处理员”
对尚未注册的路由器 e 的请求将导致 404 not found 响应。
处理错误
通过向app.use方法提供一个接受四个参数的函数,可以为应用提供一个通用的错误处理程序。在清单 7-10 中,handler函数被修改为抛出一个故意的错误。使用app.use方法设置错误处理程序,并在返回 500 响应代码之前将错误记录到控制台。
import * as express from 'express';
const portNumber = 8080;
const app = express();
app.get('/', (request, response) => {
throw new Error('Deliberate Error!');
})
app.listen(portNumber, 'localhost', () => {
console.log('Listening on localhost:' + portNumber);
});
app.use(function (error, request, response, next) {
console.error(error.message);
response.status(500).send('An error has occurred.');
});
Listing 7-10.General error handler
当您向该版本的应用发出请求时,完整的错误堆栈将显示在命令窗口中,但 web 浏览器将显示一般错误消息,如图 7-8 所示。这允许您在不公开披露任何内容的情况下,使用完整的错误信息执行日志记录操作。
图 7-8。
General error in the browser
本例中的错误处理程序是 Express 中中间件的一个很好的演示,Express 是一组组织成一个责任链的功能。每个中间件都可以处理请求和响应,允许您分离横切关注点。在实践中,这意味着您可以避免使用负责日志记录、授权和错误处理的处理程序,而是为每项职责提供单独的中间件。
您可以通过调用 app.use 来添加中间件,其中 app 是您的 Express 应用的名称。您传递的用作中间件的函数必须遵循以下两个签名之一:
- 请求处理程序
(request: Request, response: Response, next: NextFunction) => void; - 错误请求处理程序:
(error: any, request: Request, response: Response, next: NextFunction) => void;
每个中间件被依次调用,并且必须要么使用next()函数调用下一个中间件,要么结束响应。如果没有中间件结束响应,结果是 404 错误。
快递图书项目
现在我们已经介绍了 Node 和 Express 的基本概念,我们将使用一个小的示例应用来将它们放在一起。为了跟进,你需要安装一些免费软件和一些 NPM 软件包。该应用将包含多条路线,收集用户输入的数据,并将信息存储在数据库中。
- 来自
www.mongodb.com/download-center的 MongoDB 社区服务器; - 清单 7-11 中所示的 NPM 包。
为了创建这个应用,您将使用来自 NPM 的一些额外的包,这将使事情变得更快更容易。安装清单 7-11 中列出 package.json 文件的依赖项。额外的包帮助解析发送回服务器的表单,与 MongoDB 数据库对话,并解析我们的视图,这些视图是使用 Pug——一种简单的 HTML 模板语言——编写的。
{
"name": "pro-typescript-book-app",
"version": "0.0.1",
"description": "An example book application",
"main": "server.js",
"types": "server.d.ts",
"author": {
"name": "Steve.Fenton"
},
"dependencies": {
"body-parser": "1.17.2",
"express": "4.15.4",
"method-override": "2.3.9",
"mongoose": "4.11.9",
"pug": "2.0.0-rc.3"
},
"devDependencies": {
"@types/body-parser": "1.16.5",
"@types/express": "4.0.37",
"@types/method-override": "0.0.30",
"@types/mongoose": "4.7.21",
"@types/node": "8.0.26",
"@types/pug": "2.0.4"
}
}
Listing 7-11.Additional dependencies
应用的主要入口点将是服务器文件,如清单 7-12 所示。这个文件包含运行整个应用所需的所有依赖项,并且有一个单一的根来捕获对主页的请求。视图引擎也被设置为使用 Pug,模板位于“views”文件夹中。
import * as express from 'express';
import * as http from 'http';
import * as path from 'path';
import * as bodyParser from 'body-parser';
import * as methodOverride from 'method-override';
import * as routes from './routes/index';
const portNumber = 8080;
const app = express();
app.set('port', portNumber);
// Configure view templates
app.set('views', path.join(__dirname, 'views'));
app.set('view engine', 'pug');
http.createServer(app).listen(app.get('port'), () => {
console.log('Express server listening on port ' + app.get('port'));
});
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
app.use(methodOverride());
// routes
app.get('/', routes.index);
// static files, such as .css files
app.use(express.static('.'));
Listing 7-12.
Server.ts
代码被放在一个模块中,该模块位于一个包含所有路由处理程序的文件夹中,而不是直接将处理主页的函数放在服务器文件中。随着应用的增长,以这种方式组织代码将使每个文件保持较小且易于维护。主页路由处理程序的内容如清单 7-13 所示,它有一个单独的导出函数,用一个小数据模型呈现响应。
import * as express from 'express';
/* GET home page. */
export function index(request: express.Request, response: express.Response) {
response.render('index', { title: 'Express' });
};
Listing 7-13./routes/index.ts
因为我们已经将 Pug 设置为我们的视图引擎,所以我们需要提供一个模板文件用于主页。当我们调用response.render时,文件名应该与清单 7-13 中使用的名称相匹配。主页的 Pug 视图如清单 7-14 所示。该模板扩展了共享布局,可用于为所有页面或页面组提供一致的 HTML 布局。主页模板在共享布局的“内容”区域插入标题、段落和锚定标记。
extends layout
block content
h1= title
p Welcome to #{title}
a(href='/book') Books
Listing 7-14.Pug template for the home page
共享布局模板如清单 7-15 所示,它是一个简单的 HTML 文档,带有标题和样式表,以及可以由子模板填充的内容块。
doctype html
html
head
title= title
link(rel='stylesheet', href='/style.css')
body
block content
Listing 7-15.Shared layout for Pug templates
一个简单样式表的 CSS 如清单 7-16 所示。这可以放在名为 style.css 的根文件夹中的一个文件中。因为我们使用app.use(express.static('.'));设置 Express 服务器来处理静态文件,所以对样式表的请求将由静态文件中间件来处理。如果没有这个中间件,对样式表的请求将导致 404 错误,即使文件确实存在于服务器上。
body {
padding: 50px;
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
}
a {
color: #00B7FF;
}
Listing 7-16.Style Sheet
您可以使用清单 7-17 中所示的命令运行 Express 服务器。当服务器运行时,您可以通过导航到localhost:8080在浏览器中查看。
node server.js
Listing 7-17.Run the Express server
您应该看到如图 7-9 所示的输出,这意味着路由、路由处理器、视图模板和样式表都成功加载。如果您单击 books 链接,您将收到一条错误消息,因为该路线还没有处理程序。
图 7-9。
Home Page
到目前为止,示例演示了如何将路由处理程序分离到一个单独的模块中,如何为视图使用 Pug 模板,以及如何通过中间件启用静态文件。接下来,我们将研究连接到数据库来存储和检索数据。
添加图书路线
为了管理图书列表,应用需要支持一个/book路径。为了支持典型的路由,您需要添加一个路由处理程序模块、一个 Pug 模板,并在您的 Express 应用中引用路由处理程序。
清单 7-18 显示了book.ts文件的起点,它将处理对/book地址的请求。像所有请求处理程序一样,list 函数有request和response参数。处理程序对结果调用response.render方法,传入视图名称和表示要显示的数据的模型对象。
import * as express from 'express';
declare var next: (error: any) => void;
/* GET /book */
export function list(request: express.Request, response: express.Response) {
response.render('book', { 'title': 'Books', 'books': [] });
};
Listing 7-18.The routes/book.ts file
要使该处理程序工作,views 文件夹中必须有一个具有指定名称的视图。清单 7-19 显示了book.pug模板,它将呈现请求处理器提供的数据。该模板重用了layout.pug文件,这是应用的默认布局,并从模型对象中呈现title。
extends layout
block content
h1= title
p Welcome to the #{title} page.
p #{message}
Listing 7-19.The views/book.pug file
要在您的应用中注册这个路由,您需要修改server.ts文件,以添加引用book.ts文件的导入语句,并添加路由注册。清单 7-20 显示了将/book地址链接到包含请求处理程序的book.ts文件所需的另外两行。
import * as book from './routes/book';
// ...
// routes
app.get('/', routes.index);
app.get('/book', book.list);
Listing 7-20.The additions to the server.ts file
当您运行您的应用并在 web 浏览器中访问/book地址时,您应该看到一个显示消息“欢迎来到图书页面”的页面如果没有进入新页面,请检查命令窗口以查看任何错误。最常见的错误是拼写错误,例如,不小心输入了'book s '作为视图名称,而它应该是'book'。
收集数据
存储数据的第一步是提供一个允许用户输入信息的表单。清单 7-21 展示了更新后的book.pug模板,它现在有一个接受书名和作者以及可选 ISBN 标识符的表单。
HTML 属性通过将它们附加在元素名称的括号中来添加到元素中。每个输入的type和name属性以这种方式添加。清单中值得注意的属性是 ISBN 输入中的pattern属性。ISBN 不是必需的,但是如果提供了它,它必须与该属性中提供的模式相匹配。
extends layout
block content
h1= title
p Welcome to the #{title} page.
p #{message}
form(method='post')
fieldset
legend Add a Book
div
label Title *
br
input(type='text', name='book_title', required)
div
label Author *
br
input(type='text', name='author', required)
div
label ISBN
br
input(type='text', name='book_isbn', pattern='(?:(?=.{17}$)97[89] -{2}[0-9]+[ -][0-9]|97[89][0-9]{10}|(?=.{13}$)(?:[0-9]+[ -]){2}[0-9]+[ -][0-9Xx]|[0-9]{9}[0-9Xx])')
div
button Save
Listing 7-21.Adding a form to the Pug view
如果您担心必须编写像上面这样接受各种 ISBN 格式的模式,不要担心,因为这个模式和许多其他模式可以在位于 http://html5pattern.com/ 的 HTML5 模式库中找到。向 input 元素添加模式属性时,将验证输入的文本是否与表达式匹配。
为了在提交表单时对其进行处理,必须将处理表单 post 的函数添加到routes目录中的book.ts文件中。清单 7-22 显示了带有submit功能的更新文件。在这个阶段,该函数只是向视图提供一条消息,确认没有保存任何内容,因为还没有数据库。数据库将在下一节中添加。
import * as express from 'express';
declare var next: (error: any) => void;
/* GET /book */
export function list(request: express.Request, response: express.Response) {
response.render('book', { 'title': 'Books', 'books': [] });
};
/* POST /book */
export function submit(request: express.Request, response: express.Response) {
const newBook = new Book({
title: request.body.book_title,
author: request.body.author,
isbn: request.body.book_isbn
});
response.render('book', { title: 'Books', 'books': [newBook] });
}
Listing 7-22.Adding a handler to the book.ts file
要将表单 post 发送到 submi t 函数,必须在app.ts文件中注册路线。清单 7-23 显示了更新的路由,其中有新的 post 路由,它将转发由book.submit函数处理的匹配请求。
// routes
app.get('/', routes.index);
app.get('/book', book.list);
app.post('/book', book.submit);
Listing 7-23.The updated routes in the app.ts file
如果您编译并运行更新后的应用并访问/book地址,您应该会看到允许添加书籍的表单。只有在提供了标题和作者的情况下,您才能提交表单。如果您在可选的 ISBN 输入中输入任何值,它必须是有效的格式,例如 10 位数字的0-932633-42-0或 13 位数字的9780932633422。
图 7-10。
Book Page
当您成功提交表单时,结果尚未保存在数据库中。开发这个应用的下一步是持久化数据,以便以后可以可靠地检索它。
安装猫鼬
在节点应用中存储数据有许多选项。您可以使用文件系统、关系数据库(如 MySQL)或 NOSQL 数据库(如 MongoDB)。在这个例子中,MongoDB 将被 Mongoose 包装,为应用提供数据访问。Mongoose 可以简化验证、查询和类型转换等操作。
在执行本节中的代码之前,您需要设置您的数据库。要为您的平台下载 MongoDB,请访问 https://www.mongodb.com/download-center 。下载是一个简单的安装程序,适用于您选择的平台。
MongoDB 将您的数据存储在文件系统上,因此您需要设置一个文件夹用于存储。默认情况下,MongoDB 会查找一个c:\data\db目录,因此您应该在继续之前添加这个目录。如果不添加这个文件夹,当您尝试启动 MongoDB 服务器时,它会立即停止。如果愿意,您可以将数据放在不同的目录中。在启动数据库服务器时,您还需要提供 MongoDB 的路径。现在,只需添加默认目录。
要启动 MongoDB 数据库服务器,请在命令窗口中运行清单 7-24 中所示的代码。这个命令是为 MongoDB 3.4 版编写的,所以如果您有不同的版本,您将需要更改路径。
C:\Program Files\MongoDB\Server\3.4\bin\mongod.exe
Listing 7-24.Running the database server
您应该会收到一条消息,说明服务器“正在等待[端口号]上的连接”。当您从应用连接到数据库时,将需要此消息中显示的号码(通常为 27017)。如果出现任何错误,请仔细检查是否已经设置了c:\data\db目录。
Mongoose 模块和类型定义应该已经可用了,因为它是在本章前面添加到package.json中的。现在,您应该已经准备好开始保存数据所需的一切。下一节将演示对 Express Book 项目的更改,以存储和检索用户输入的图书。
存储数据
为了存储用户提交新书时收到的数据,必须更改routes目录中的book.ts文件,以调用新安装的数据库。
清单 7-25 显示了更新后的处理程序、list处理程序显示数据库中的书籍,而submit处理程序保存新的提交。连接数据库的代码在函数之外,在函数之间共享。下面显示了所有更改的更详细的演练。
import * as express from 'express';
import * as mongoose from 'mongoose';
declare var next: (error: any) => void;
// MongoDB typically runs on port 27017
mongoose.connect('mongodb://localhost:27017/books', { useMongoClient: true });
// Defines a book
interface Book extends mongoose.Document {
title: string;
author: string;
isbn: string;
}
// Defines the book database schema
const bookSchema = new mongoose.Schema({
title: String, author: String, isbn: String
});
const Book = mongoose.model<Book>('Book', bookSchema);
/* GET /book */
export function list(request: express.Request, response: express.Response) {
Book.find({})
.then((res) => {
response.render('book', { 'title': 'Books', 'books': res });
})
.catch((err) => {
return next(err);
});
};
/* POST /book */
export function submit(request: express.Request, response: express.Response) {
const newBook = new Book({
title: request.body.book_title,
author: request.body.author,
isbn: request.body.book_isbn
});
newBook.save()
.then((res) => {
response.redirect('/book');
})
.catch((err) => {
return next(err);
});
}
Listing 7-25.The updated routes/book.ts file
使用mongoose.connect调用建立数据库连接。示例中的连接字符串使用端口 27017;您应该使用启动MongoDB服务器时显示的端口号。当你的应用连接到数据库时,每个连接都会被记录到MongoDB命令窗口。
给变量bookSchema分配一个新的 Mongoose 模式。该模式定义了要存储在集合中的文档的形状。Mongoose 为您设置了 MongoDB 集合,并可以处理默认值和验证。使用title、author,和ISBN属性设置图书的模式,这些属性都被分配了类型String。模式的定义与 TypeScript 类型注释惊人地相似。由于语句的上下文,TypeScript 编译器足够聪明,能够意识到它们不是类型批注;因此,它不使用类型擦除将它们从编译后的输出中删除。问题中的String类型不是支持 TypeScript 中的string类型注释的String接口,而是一个mongoose.Schema.Types.String。如果你不小心使用了小写的string类型,编译器会对你的错误给出警告。
变量Book被赋予一个 Mongoose 创建的模型对象。这让您不必自己编写一个Book类的实现。您可以使用这个模型在任何需要的时候创建书籍的新实例,就像您编写自己的类一样。
虽然这需要几段文字来解释,但还是有必要重温一下代码清单,以确认可以连接到数据库,并在三行代码中为图书数据设置模式。这种设置用于处理请求的两个函数。
list函数调用 Mongoose 提供的Book.find方法来检索记录。您可以向用于过滤结果的find方法提供一个对象。该对象可以是图书模式的部分匹配。例如,您可以使用{ author: 'Robert C. Martin' }来检索 Bob 叔叔的所有书籍。在本例中,空对象表示您想要集合中的所有文档。
因为查询是异步执行的,所以依赖于结果的代码放在适当的then块中。如果您忘记将代码放在then块中,响应将在查询完成之前发送。使用catch模块处理错误。books集合被添加到传递给视图的模型对象中。
Note
尽管该示例使用一个空对象来查询集合并检索所有书籍,但是随着越来越多的书籍添加到数据库中,该查询会变得越来越慢。您可以使用 skip 和 limit 查询方法对结果进行分页。
submit函数用用户提交的数据实例化一个新的Book对象,然后调用 Mongoose 提供的save方法。同样,数据库调用是异步的,一旦查询得到解决,承诺就会继续。在代码清单中,当记录保存成功时,响应只是重定向到list动作。提交后重定向请求可以防止用户通过刷新浏览器意外地重新提交相同的数据。成功提交后重定向的模式称为 Post Redirect Get 模式。
现在,路由处理程序正在将图书数据传递给视图,可以更新视图以显示数据。添加表格的附加标记如清单 7-26 所示。
table
thead
tr
th Title
th Author
th ISBN
tbody
if books
each book in books
tr
td= book.title
td= book.author
td= book.isbn
Listing 7-26.The Pug table template
Pug 模板中的each循环重复了books集合中每一项的嵌套输出。表格单元格使用简写语法声明(元素名后跟一个=)。这意味着数据变量不需要像标题一样包含在通常的#{}分隔符中。
Pug 的each循环将处理一个空数组,而不是一个未定义的值。示例中each循环之前的if语句防止未定义的值到达each循环。
现在,您拥有了一个可以保存和显示书籍的全功能应用。您可以在表单中输入一本书,如图 7-11 所示。
图 7-11。
Completed Book Form
当您点击保存按钮时,您的图书将被保存,您将看到图 7-12 中的页面。您可以随时返回到您的应用,存储的图书仍然可用。
图 7-12。
Stored Book
存储在 MongoDB 中的数据是持久的,这意味着您可以重新启动机器,数据仍然会被存储。这为您提供了一个永久的存储,除非机器完全崩溃,在这种情况下,您可能需要为您的数据考虑一个合适的备份策略。您可以在 https://docs.mongodb.com/manual/core/backups/ .了解更多关于 MongoDB 备份方法的信息
摘要
JavaScript 对于 web 服务器来说并不陌生,并且已经获得了巨大的吸引力,这要归功于 Node 和通过 Node Package Manager 提供的数以千计的模块。随着编写更大的程序在 Node 上运行,TypeScript 提供的语言特性和工具的价值迅速增加。大量的时间可能会浪费在简单的错误上,比如将依赖于异步调用的代码放在回调函数之外,或者在打算使用String时使用string,而 TypeScript 可以帮助您避免这些常见的错误。
Express framework 是一种快速入门的方式,它将为使用过 Sinatra(或 Nancy in)的程序员提供一些熟悉感。网)。即使对于那些不熟悉这种实现风格的人来说,路由处理器和视图的分离也是显而易见的。与在 Node 中处理低级别的 HTTP 请求和响应相比,使用 Express 将提高您的生产率。
Mongoose 为数据库扮演了类似的角色,提供了许多快捷方式来提高您的工作效率。如果您想降低一个级别,通过直接调用 MongoDB 来存储和检索数据,自己处理模型和验证,MongoDB 并不特别棘手。
尽管本章很高兴地保留了 Express 附带的许多默认设置,但您并不局限于使用这些默认设置。用一行代码替换模板引擎和中间件是微不足道的。
要点
- JavaScript 已经在 web 服务器上运行了 20 多年。
- Node 可以在任何平台上愉快地运行。
- 您可以从 NPM 的@types 组织获得节点和许多节点模块的类型信息。
- Express 提供了一个轻量级的、灵活的应用框架,比低级别的节点 HTTP 请求和响应更容易使用。
- Mongoose 和 MongoDB 通过异步 API 提供简单的持久性。
八、异常、内存和性能
异常处理程序的主要职责是让程序员摆脱错误,让用户大吃一惊。只要你牢记这条基本原则,你就不会错得太离谱。—Verity Stob
尽管缺乏语言特性或运行时环境的吸引力,理解异常和内存管理将有助于您编写更好的 TypeScript 程序。对于使用过 C#、Java、PHP 或许多其他语言的程序员来说,JavaScript 和 TypeScript 中的异常可能看起来很熟悉,但是有一些细微但重要的区别。异常处理和内存管理这两个主题有着千丝万缕的联系,因为它们共享一个语言特性,这将在本章的后面描述。
记忆管理的主题经常被民间传说、谎言和盲目应用的最佳实践所支配。本章讨论了内存管理和垃圾收集的事实,并解释了如何进行测量来测试您的优化想法,而不是应用一个可能影响很小或没有影响的实践(甚至比原始代码执行得更差)。这将简单地引出性能的主题。
例外
异常用于指示程序或模块无法继续处理。就其本质而言,它们只应在真正特殊的情况下提出。因此得名!通常,异常用于指示程序的状态是无效的,或者继续处理是不安全的。
虽然每当例程将不合意的值作为参数传递时就开始发出异常可能很诱人,但处理您可以预料到的输入而不引发异常通常会更好。
当您的程序遇到异常时,它将显示在控制台中,除非用代码进行处理。控制台允许程序员编写消息,它会自动记录运行程序时发生的任何异常。
您可以在所有现代 web 浏览器中检查控制台的异常。快捷键因浏览器而异,因平台而异,但如果CTRL + SHIFT + I在您的 Windows 或 Linux 机器上无法工作,或者CMD + OPT + I在您的 Mac 上无法工作,您可以尝试 F12 键,或者在浏览器菜单中找到列在“开发人员工具,浏览器控制台”或类似名称下的工具。对于 Node,错误和警告输出将出现在用于运行 HTTP 服务器的控制台窗口中。
抛出异常
要在 TypeScript 程序中引发异常,可以使用throw关键字。尽管您可以对任何对象使用这个关键字,但是最好提供一个包含错误消息的字符串,或者包装错误消息的Error对象的实例。
清单 8-1 显示了一个典型的异常被抛出以防止一个不可接受的输入值。当用数字调用errorsOnThree函数时,它返回数字,除非用数字 3 调用,在这种情况下会引发异常。
function errorsOnThree(input: number) {
if (input === 3) {
throw new Error('Three is not allowed');
}
return input;
}
const result = errorsOnThree(3);
Listing 8-1.Using the throw keyword
本例中的一般Error类型可以替换为自定义异常。您可以使用实现清单 8-2 中所示的Error接口的类来创建一个定制异常。Error接口确保你的类有一个name和message属性。
清单 8-2 中的toString方法不是Error接口所必需的,但是在很多情况下被用来获得错误的字符串表示。如果没有这个方法,来自Object的toString的默认实现将被调用,它将把[object Object]写到控制台。通过将toString方法添加到ApplicationError类中,您可以确保当异常被抛出并被记录时显示一条适当的消息。
class ApplicationError implements Error {
public name = 'ApplicationError';
constructor(public message: string) {
if (typeof console !== 'undefined') {
console.log(`Creating ${this.name} "${message}"`);
}
}
toString() {
return `${this.name}: {this.message}`;
}
}
Listing 8-2.Custom error
您可以在throw语句中使用自定义异常来对已经发生的错误进行分类。一种常见的异常模式是创建一个通用的ApplicationError类,并从它继承来创建更多特定种类的错误。然后,任何处理异常的代码都能够根据抛出的错误类型采取不同的操作,这在后面的异常处理一节中有演示。
清单 8-3 显示了一个继承自ApplicationError类的特定的InputError类。errorsOnThree函数使用InputError异常类型来突出显示错误是对错误输入数据的响应。
class ApplicationError implements Error {
public name = 'ApplicationError';
constructor(public message: string) {
if (typeof console !== 'undefined') {
console.log(`Creating ${this.name} "${message}"`);
}
}
toString() {
return `${this.name}: {this.message}`;
}
}
class InputError extends ApplicationError {
}
function errorsOnThree(input: number) {
if (input === 3) {
throw new InputError('Three is not allowed');
}
return input;
}
Listing 8-3.Using inheritance to create special exception types
例子中的InputError只是简单的扩展了ApplicationError;它不需要实现任何属性或方法,因为它只是提供一类在程序中使用的异常。您可以创建异常类来扩展ApplicationError,或者进一步专门化ApplicationError的子类。
Note
你应该把本机类型视为神圣的,永远不要抛出这种类型的异常。通过创建自定义异常作为ApplicationError类的子类,您可以确保Error类型是为在真正的异常情况下在您的代码之外使用而保留的。
异常处理
当抛出异常时,除非异常得到处理,否则程序将被终止。要处理异常,可以使用 try-catch 块、try-finally 块,甚至 try-catch-finally 块。在任何一种情况下,可能导致引发异常的代码都被包装在 try 块中。
清单 8-4 显示了一个 try-catch 块,它处理前面部分中来自errorsOnThree函数的错误。由catch块接受的参数代表抛出的对象,例如,Error实例或自定义ApplicationError对象,这取决于您在throw语句中使用的是哪一个。
try {
const result = errorsOnThree(3);
} catch (err) {
console.log('Error caught, no action taken');
}
Listing 8-4.Unconditional catch block
err参数作用于catch块,使其等同于用let关键字声明的变量,而不是用var关键字,如第四章所述。
在支持 try-catch 块的语言中,允许捕获特定的异常类型是很常见的。这使得catch块只适用于特定类型的异常,对于其他类型的异常,就像没有 try-catch 块一样。建议使用这种技术,以确保您只处理您知道可以恢复的异常,留下真正意外的异常来终止程序,并防止状态的进一步恶化。
目前还没有符合标准的方法来有条件地捕捉异常,这意味着要么全部捕捉,要么一个都不捕捉。如果您只想处理特定类型的异常,您可以在catch语句中检查类型,并重新抛出任何与类型不匹配的错误。
清单 8-5 显示了一个异常处理例程,它处理ApplicationError自定义异常,但会抛出任何其他类型的异常。在 if 语句中,err变量的类型被缩小为ApplicationError类型。
try {
const result = errorsOnThree(3);
} catch (err) {
if (err instanceof ApplicationError) {
console.log('Error caught, no action taken');
}
throw err;
}
Listing 8-5.Checking the type of error
Note
通过只处理自定义异常,可以确保只处理已知可以恢复的异常类型。如果您使用默认的catch块而没有instanceof检查,那么您就要对程序中可能出现的每种类型的异常负责。
这个例子将允许 catch 块处理一个ApplicationError,或者一个ApplicationError的子类,比如本章前面描述的InputError。为了说明在类层次结构的不同级别处理异常的效果,图 8-1 显示了一个更复杂的层次结构,它扩展了ApplicationError和Input类。
图 8-1。
Error class hierar chy
当您选择处理InputError类异常时,您将处理如图 8-2 所示的四种异常:InputError、BelowMinError、AboveMaxError和InvalidLengthError。所有其他异常都将在调用堆栈中向上传递,就像它们未被处理一样。
图 8-2。
Handling InputError exceptions
如果您要处理App licationError类别的异常,那么您将处理如图 8-3 所示的层次结构中的所有七个自定义异常。
图 8-3。
Handling ApplicationError exceptions
一般来说,程序越深入,处理的异常就应该越具体。如果您在低级代码附近工作,您将处理非常特殊类型的异常。当您在更接近用户界面的地方工作时,您会处理更一般的异常。
随着对性能的讨论,异常很快会再次出现,因为在程序中创建和处理异常会产生性能成本。尽管如此,如果您只是用它们来表示例程无法继续,您就不应该担心它们的运行时开销。
记忆
当你用高级语言如 TypeScript 或 JavaScript 编写程序时,你将从自动内存管理中获益。您创建的所有变量和对象都将被管理,因此您永远不会超出边界,也不会处理悬空指针或损坏的变量。事实上,您可能遇到的所有可管理的内存问题都已经为您解决了。但是,有些内存安全类别不能自动处理,例如内存不足错误,它指示系统资源已经耗尽,无法继续处理。
本节涵盖了您可能会遇到的问题类型以及避免这些问题需要了解的内容。
释放资源
在 TypeScript 中,您不太可能遇到非托管资源。大多数 API 遵循异步模式,接受操作完成时将调用的方法参数。现代 API 将通过一个承诺来公开这一点。因此,您永远不会在程序中保存对非托管资源的直接引用。例如,如果您想使用 proximity API,它检测物体何时靠近传感器,您可以使用清单 8-6 中的代码。
const sensorChange = function (reading) {
const proximity = reading.near
? 'Near'
: 'Far';
alert(proximity);
}
window.addEventListener('userproximity', sensorChange, true);
Listing 8-6.Asynchronous pattern
异步模式意味着,尽管您可以从近程传感器获得信息,但您的程序从不负责资源或通信通道。如果您碰巧遇到这样的情况,您确实持有对必须管理的资源的引用,您应该使用 try-finally 块来确保资源被释放,即使发生错误也是如此。
清单 8-7 中的示例假设可以直接使用接近传感器来获取读数。
const sensorChange = function (reading) {
var proximity = reading.near ?
'Near' : 'Far';
alert(proximity);
}
const readProximity = function () {
const sensor = new ProximitySensor();
try {
sensor.open();
const reading = sensor.read();
sensorChange(reading);
} finally {
sensor.close();
}
}
window.setInterval(readProximity, 500);
Listing 8-7.Imaginary unmanaged proximity sensor
finally块将确保传感器的close方法被调用,该方法执行清理并释放任何资源。即使调用read方法或sensorChange函数时出现错误,finally块也会执行。
清单 8-8 中显示了类似 promise 接口的等价示例。在处理承诺时,通常要么执行“then”块,要么在出现错误时执行“catch”块。在所有情况下都会调用 finally 块。
const sensorChange = function (reading) {
var proximity = reading.near ?
'Near' : 'Far';
alert(proximity);
}
const readProximity = function () {
const sensor = new ProximitySensor();
sensor.open()
.then(() => {
return sensor.read();
})
.then((reading) => {
sensorChange(reading);
})
.finally(() => {
sensor.close();
});
}
window.setInterval(readProximity, 500);
Listing 8-8.Umanaged proximity sensor with promise-like interface
在前两节中,我用“catch”介绍了异常处理,用“finally”介绍了资源管理在所有情况下,您都可以将两者结合起来执行异常和内存管理。
碎片帐集
当不再需要内存时,需要将其释放,以便分配给程序中的其他对象。用于确定是否可以释放内存的过程称为垃圾收集。根据运行时环境的不同,您会遇到几种垃圾收集方式。
旧的 web 浏览器可能使用引用计数垃圾收集器,当对一个对象的引用数达到零时释放内存。这在表 8-1 中进行了说明。这是一种非常快速的垃圾收集方式,因为引用计数一达到零就可以释放内存。但是,如果在两个或多个对象之间创建了循环引用,这些对象都不会被垃圾回收,因为它们的计数永远不会达到零。
现代 web 浏览器用标记和清除算法解决了这个问题,该算法检测所有从根可到达的对象,并对不能到达的对象进行垃圾收集。尽管这种垃圾收集方式可能需要更长的时间,但它不太可能导致内存泄漏。为了防止浏览器 UI 冻结,一些 JavaScript 引擎将垃圾收集偷偷放入空闲时间,这意味着它对浏览体验的影响较小。
表 8-1。
Reference counting garbage collection
| 目标 | 引用计数 | 内存取消分配 | | --- | --- | --- | | `Object A` | one | 不 | | `Object B` | one | 不 | | `Object C` | one | 不 | | `Object D` | one | 不 | | `Object E` | Zero | 是 |表 8-1 中的相同对象如图 8-4 所示。使用引用计数算法,对象 A 和对象 B 都保留在内存中,因为它们相互引用。这些循环引用是旧浏览器中内存泄漏的来源,但这个问题通过标记-清除算法得到了解决。对象 A 和对象 B 之间的循环引用不足以使对象在垃圾收集中幸存下来,因为只剩下可从根访问的对象。
图 8-4。
Mark and sweep
大多数现代垃圾收集器通过几代来提升对象,最频繁和最有效的收集是对短命对象进行的。随着对象生存时间的延长,它们通常被检查的频率会降低,收集的速度也会变慢。完整的垃圾收集还可以包括压缩步骤,以优化内存使用。
使用标记-清除垃圾收集算法意味着您很少需要担心 TypeScript 程序中的垃圾收集或内存泄漏。
表演
毫无疑问,追求效率会导致滥用。程序员浪费大量的时间去思考或担心他们程序中非关键部分的速度,当考虑到调试和维护时,这些提高效率的尝试实际上有很大的负面影响。我们应该忘记小的效率,比如说 97%的时候:过早的优化是万恶之源。然而,我们不应该错过这关键的 3%的机会。—唐纳德·克努特
这不是 Donald Knuth(1974 年《计算调查》中的结构化编程与 go to 语句)第一次被引用关于性能和优化,当然也不会是最后一次。他的话,至少在这方面,经受住了时间的考验(尽管它们来自一篇为后藤言论辩护的论文——随着时间的推移,这种情绪多少有些下降)。
如果性能问题出现在可测量的性能问题之前,您应该避免优化。有许多文章声称使用局部变量将比全局变量快,您应该避免闭包,因为它们很慢,或者对象属性比变量慢。虽然这些通常是正确的,但是把它们当作设计规则会导致糟糕的程序设计。
优化的黄金法则是,您应该衡量两种或更多种潜在设计之间的差异,并确定性能提升是否值得您为获得它们而必须做出的任何设计权衡。
Note
对于您的 TypeScript 程序,测量执行时间需要在多个平台上运行测试。否则,您可能会在一个浏览器中变得更快,但在另一个浏览器中变得更慢。
清单 8-9 中的代码将用于演示一个简单的性能测试。将测试轻量级CommunicationLines类。该类包含一个方法,该方法接受一个teamSize,并使用著名的 n(n–1)/2 算法计算团队成员之间的通信行数。名为testCommunicationLines的函数实例化了该类,并成功测试了团队规模为 4 人和 10 人的两个案例,这两个案例分别有 6 条和 45 条通信线路。
class CommunicationLines {
calculate(teamSize: number) {
return (teamSize * (teamSize - 1)) / 2
}
}
function testCommunicationLines() {
const communicationLines = new CommunicationLines();
let result = communicationLines.calculate(4);
if (result !== 6) {
throw new Error('Test failed for team size of 4.');
}
result = communicationLines.calculate(10);
if (result !== 45) {
throw new Error('Test failed for team size of 10.');
}
}
testCommunicationLines();
Listing 8-9.Calculating lines of communication
清单 8-10 中的Performance类在一个方法中包装了一个回调函数,该方法使用第四章中讨论的高保真定时器使用performance.now方法来为操作计时。为了得到一个公平的度量,默认情况下,Performance类运行代码 10,000 次,尽管这个数字可以在调用run方法时被覆盖。
Performance类的输出包括执行代码 10,000 次的总时间以及每次迭代的平均时间。
export class Performance {
constructor(private func: Function, private iterations: number) {
}
private runTest() {
if (!performance) {
throw new Error('The performance.now() standard is not supported in this runtime.');
}
const errors: number[] = [];
const testStart = performance.now();
for (let i = 0; i < this.iterations; i++) {
try {
this.func();
} catch (err) {
// Limit the number of errors logged
if (errors.length < 10) {
errors.push(i);
}
}
}
const testTime = performance.now() - testStart;
return {
errors: errors,
totalRunTime: testTime,
iterationAverageTime: (testTime / this.iterations)
};
}
static run(func: Function, iterations = 10000) {
const tester = new Performance(func, iterations);
return tester.runTest();
}
}
Listing 8-10.Performance.ts runner
要使用Performance类来度量程序,必须导入代码,并通过将函数传递给Performance类的run方法来替换对testCommunicationLines函数的调用,如清单 8-11 所示。
import { Performance } from './Listing-8-010';
class CommunicationLines {
calculate(teamSize: number) {
return (teamSize * (teamSize - 1)) / 2
}
}
function testCommunicationLines() {
const communicationLines = new CommunicationLines();
let result = communicationLines.calculate(4);
if (result !== 6) {
throw new Error('Test failed for team size of 4.');
}
result = communicationLines.calculate(10);
if (result !== 45) {
throw new Error('Test failed for team size of 10.');
}
}
const result = Performance.run(testCommunicationLines);
console.log(result.totalRunTime + ' ms');
Listing 8-11.Running the performance test
此代码的结果是控制台记录了 2.73 毫秒的总运行时间。这意味着 10,000 次迭代(对通信线路算法的 20,000 次调用)的整个运行时间不到 3 ms。在大多数情况下,这样的结果很好地表明您在错误的地方寻找优化机会。
通过调整清单 8-12 中所示的代码,有可能得到非常不同的结果。对代码所做的唯一更改是在 7 条通信线路中检查对团队规模为 4 的communicationLines.calculate的调用结果。该测试将失败,并将引发异常。
import { Performance } from './Listing-8-010';
class CommunicationLines {
calculate(teamSize: number) {
return (teamSize * (teamSize - 1)) / 2
}
}
function testCommunicationLines() {
const communicationLines = new CommunicationLines();
let result = communicationLines.calculate(4);
if (result !== 7) {
throw new Error('Test failed for team size of 4.');
}
result = communicationLines.calculate(10);
if (result !== 45) {
throw new Error('Test failed for team size of 10.');
}
}
const result = Performance.run(testCommunicationLines);
console.log(result.totalRunTime + ' ms');
Listing 8-12.Running the performance test with exceptions
运行带有失败测试的代码以及异常的创建和处理导致总运行时间为 214.45 ms,比第一次测试慢 78 倍。可以使用这些数据来指导您的设计决策。您可能想要多次重复测试,或者尝试不同的迭代大小,以确保获得一致的结果。
下面是使用清单 8-10 中的Performance类收集的一些数字,以证明本节开始时关于优化的声明。使用一个简单但有限的测试,每次迭代的基线时间为 0.74 毫秒,结果如下(其中数字越大表示执行时间越慢):
- 全局变量:0.80 毫秒(每次迭代慢 0.06 毫秒)
- 闭包:1.13 毫秒(每次迭代慢 0.39 毫秒)
- 属性:1.48 毫秒(每次迭代慢 0.74 毫秒)
超过 10,000 次执行后,您可以看到执行时间上的微小差异,但重要的是要记住,由于对象复杂性、嵌套深度、创建的对象数量以及许多其他因素的差异,您的程序将返回不同的结果。在进行任何优化之前,请确保您已经进行了测量,这样您就可以比较任何更改前后的性能,以确定它们是否产生了积极的影响。
摘要
本章涵盖了三个重要的主题,它们可能是任何用 TypeScript 编写的大型应用的基础。在大多数情况下,这三个领域很可能是跨领域的关注点,在您编写大量需要更改的代码之前,可能更容易考虑这些问题。
在程序中使用异常来处理真正的异常状态可以防止程序数据的进一步损坏。您应该创建自定义异常来帮助管理不同种类的错误,并测试您的catch块中的类型,以便只处理您知道可以恢复的错误。
现代运行时都使用可靠的标记-清除算法来处理内存,这种算法不会像早期的引用计数垃圾收集器那样遭受循环引用内存泄漏。人们普遍认为程序员在编码时不需要考虑垃圾收集,但是如果您可以测量性能问题并发现垃圾收集是问题所在,您可能会决定通过创建更少的对象来帮助垃圾收集器进行管理。
无论何时进行优化,您都应该首先测量程序的性能,以证明在进行更改时您关于优化的假设是否正确。您应该在多个环境中测量您的变化,以确保您在所有环境中都提高了速度。
要点
- 您可以对任何对象使用
throw关键字,但是最好使用自定义错误的子类。 - 您可以用 try-catch-finally 块处理异常,其中您必须指定一个
catch或finally块,或者两个都指定。 - 处理承诺时,可以使用 catch 块、finally 块,或者两者都用。
- 您不能可靠地只捕捉自定义异常,但是您可以在
catch块中测试异常类型。 - 您遇到的大多数 API 将遵循异步或类似 promise 的模式,但是如果您发现必须管理资源,请使用 try-finally 块进行清理。
- 说到性能,您需要前后测量来备份您以优化的名义更改的任何代码。
九、使用 JavaScript 库
我并不是说使用现有的软件或库不好。我的意思是,总是要在一方面最小化工作和另一方面最小化冗余代码之间进行权衡。我的意思是,当您需要的现有库中的特性很少时(比如说少于 20%),您应该考虑编写自己的代码。也许永远背负额外的 80%是不值得的。—利·韦鲁
在编写框架、工具包、有用的函数和有用的代码片段方面,JavaScript 社区是最繁忙的社区之一。如果您搜索任何类型的框架或工具包,您可能会找到大量选项。事实上,选项的数量既是一件好事也是一件坏事,尽管你可以毫不费力地找到行为驱动的测试框架、单元测试框架、模型视图控制器(MVC)框架、模型视图视图模型(MVVM)框架、网络工具包、浏览器填充等等。从无数选项中选择一个来使用并不是一件容易的事情。
一旦你权衡了你的选择,你就可以开始在你的 TypeScript 程序中使用这个框架了。在运行时,你的程序和框架都是普通的 JavaScript,但是在设计时和编译时,你将把你的类型脚本代码和普通的 JavaScript 库混合在一起。因为 TypeScript 编译器不知道 JavaScript 文件中提供的操作,所以您需要以类型定义的形式提供提示,以获得与 TypeScript 库相同级别的工具支持。
编译器使用类型定义检查程序,语言服务使用类型定义在开发工具中提供自动完成功能。所有的类型定义都被编译器删除了,这意味着它们不会给你的产品代码增加任何负担。本章包括一个示例应用,演示当您需要在 TypeScript 程序中包含第三方 JavaScript 代码时,如何为其创建类型定义。
为了说明类型定义对现有代码的好处,让我们来看看在普通 JavaScript 中使用 jQuery 的典型开发工作流程。
- 键入一个选择器和方法,如
$('#elem').on( - 在 jQuery 文档中搜索
on方法的签名 - 在代码和文档之间切换,以完成代码行
这是 jQuery 中除了最常见的操作之外的所有操作的典型表示,我很大方地没有提到任何复制和粘贴示例的操作(我们都做过)。如果我们的开发人员工作流程甚至包括在文档和代码之间切换,那么一定有更好的方法。类型定义是更好的方法。
通过包含 jQuery 的类型定义,您将获得 jQuery 成员的智能自动完成,从而节省了编辑器和文档之间的切换。GitHub 上有一个官方的类型定义库(明确类型化)。所有这些定义都可以从 NPM 的类型组织中安装,如图 9-1 所示,或者通过运行清单 9-1 中的命令来安装。
图 9-1。
Adding the jQuery t ype definition
请注意,在这两种情况下,类型定义都保存为开发依赖项,因为它们在运行时是不需要的。
npm install @types/jquery --save-dev
Listing 9-1.NPM install command for type definitions
但是,在我们通过检索现有的类型定义走得太远之前,让我们从头开始,涵盖可能没有可用定义的场景。这意味着你可以处理你自己的 JavaScript 代码,或者还没有社区贡献的利基库,就像流行的框架一样容易。
创建类型定义
为了说明类型定义的创建,本章使用 Knockout 作为 JavaScript 库的例子。Knockout 是一个 MVVM 框架,它通过将模型映射到视图来简化动态用户界面,并在发生变化时保持两者同步。尽管 Knockout 用于演示从头创建类型定义的过程,但这种技术可以用来以 TypeScript 能够理解的方式描述任何 JavaScript 代码,甚至是您自己的遗留库。
当然,如果你要在你的程序中添加一个流行的库,比如 Knockout,很可能有人已经开始创建类型定义了。因此,在你花时间做一个你自己的之前,检查一下明确类型项目的清单或者通过 NPM(在那里你会找到@types/knockout)。
如果你正在使用一个没有列出的开源库,在你创建了一个类型定义之后,你可以把它提交给明确类型化项目,以便将来帮助其他程序员。
图 9-2 中显示了一个正在运行的应用示例。该应用允许您预订座位并从一系列食物选项中进行选择。
图 9-2。
Knockout applicat ion
使用挖空创建 TypeScript 应用
本章中的应用允许乘客在航空公司预订座位和机上餐食。该应用由一个 HTML 页面和一个包含将数据绑定到视图的剔除代码的app.ts文件组成。清单 9-2 中显示的 HTML 页面提供了应用的视图,来自于
这个例子中有趣的部分是 Knockout 用来将视图模型绑定到 HTML 页面的data-bind属性。每个data-bind属性都有一个表达式,描述数据应该绑定到元素的什么位置,例如value属性或内部text,以及应该显示哪些数据。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Knockout App</title>
<link rel="stylesheet" href="app.css" type="text/css" />
</head>
<body>
<h1>Your seat reservations (<span data-bind="text: seats().length"></span>)</h1>
<table>
<thead>
<tr>
<th>Passenger name</th>
<th>Meal</th>
<th>Surcharge</th>
<th></th>
</tr>
</thead>
<tbody data-bind="foreach: seats">
<tr>
<td><input data-bind="value: name" /></td>
<td><select data-bind="options: $root.availableMeals, value: meal, optionsText: 'mealName'"></select></td>
<td data-bind="text: formattedPrice"></td>
<td><a href="#" data-bind="click: $root.removeSeat">Remove</a></td>
</tr>
</tbody>
</table>
<button data-bind="click: addSeat, enable: seats().length < 5">Reserve another seat</button>
<h2 data-bind="visible: totalSurcharge() > 0">
Total surcharge: $<span data-bind="text: totalSurcharge().toFixed(2)"></span>
</h2>
<script src="knockout.js"></script>
<script src="app.js"></script>
</body>
</html>
Listing 9-2.The HTML page
app.ts文件包含使用 Knockout 将数据绑定到视图的代码,如清单 9-3 所示。在本节中,该文件不会被更改,但它将用于排除类型定义,这些定义是克服编译器错误并为剔除提供高质量的自动完成和类型检查所必需的。
// Class to represent a row in the seat reservations grid
function SeatReservation(name, initialMeal) {
var self = this;
self.name = name;
self.meal = ko.observable(initialMeal);
self.formattedPrice = ko.computed(function () {
var price = self.meal().price;
return price ? "$" + price.toFixed(2) : "None";
});
}
// Overall viewmodel for this screen, along with initial state
function ReservationsViewModel() {
var self = this;
// Non-editable catalog data - would come from the server
self.availableMeals = [
{ mealName: "Standard (sandwich)", price: 0 },
{ mealName: "Premium (lobster)", price: 34.95 },
{ mealName: "Ultimate (whole zebra)", price: 290 }
];
// Editable data
self.seats = ko.observableArray([
new SeatReservation("Steve", self.availableMeals[0]),
new SeatReservation("Bert", self.availableMeals[0])
]);
// Computed data
self.totalSurcharge = ko.computed(function () {
var total = 0;
for (var i = 0; i < self.seats().length; i++)
total += self.seats()[i].meal().price;
return total;
});
// Operations
self.addSeat = function () {
self.seats.push(new SeatReservation("", self.availableMeals[0]));
}
self.removeSeat = function (seat) { self.seats.remove(seat) }
}
ko.applyBindings(new ReservationsViewModel(), document.body);
Listing 9-3.The program in app.ts
如果您将这些文件放入您的开发环境中,由于 Knockout 的ko变量未知,您将从 TypeScript 编译器收到许多错误。这些错误的一个例子如图 9-3 所示。
图 9-3。
The compiler errors
让编译器静音
如果您只是对让编译器沉默感兴趣,您只需要提供一个快速提示,告诉编译器您将对使用ko变量导致所有错误的所有代码负责。提供这个提示的类型定义如清单 9-4 所示。
类型定义通常放在名为knockout.d.ts的文件中,并在app.ts中使用引用注释或导入语句进行引用。
declare var ko: any;
Listing 9-4.The quick type definition fix
当你使用这种类型定义时,你拒绝了编译器检查你的程序的提议,你将不会得到自动完成。尽管这是一个简单的快速解决方案,但是您可能想要编写一个更全面的类型定义。
图 9-4。
The first iteration
图 9-4 显示编辑器中的类型信息还没有用,但是所有的错误都消失了。
迭代改进类型定义
编写类型定义的一个好处是,您可以以很小的增量编写它们。这意味着您可以决定在类型定义上投入多少努力,以换取每次增量提供的类型检查和自动完成的好处。
清单 9-5 展示了敲除类型定义的一个小的增量改进。Knockout接口为应用中使用的所有一级属性提供类型信息:applyBindings、computed、observable和observableArray。没有给出这四个属性的具体细节;它们被简单地指定为any类型。
声明的ko变量被更新以使用新的Knockout接口,而不是用于使编译器静默的any类型。
interface Knockout {
applyBindings: any;
computed: any;
observable: any;
observableArray: any;
}
declare var ko: Knockout;
Listing 9-5.First-level type definition
尽管这个更新的定义很简单,但它可以防止许多常见的错误,否则在应用中注意到不正确的行为之前,这些错误是不会被发现的。清单 9-6 显示了编译器根据这个一级类型定义捕捉到的两个错误示例。
// Spelling error caught by the compiler
self.meal = ko.observabel(initialMeal);
// Non-existent method caught by compiler
ko.apply(new ReservationsViewModel(), document.body);
Listing 9-6.
Compiler errors
for incorrect code
在应该使用observable的地方拼错了observabel,在应该使用applyBindings的地方拼错了不存在的apply调用,都会导致编译器错误。这是编译器所能做到的最大限度,因为在接口中只指定了名称,而没有指定方法签名。
图 9-5 显示了带有更新定义的改进的自动完成功能。虽然列出了成员,但尚未提供它们的类型信息。
图 9-5。
The second iteration
为了增加类型定义的细节,值得参考库的官方文档。通过在创建定义时引用它一次,您可以省去每次编写代码时都要做的工作。在applyBindings的情况下,文档说明该方法可以接受以下一个或两个参数:
viewModel—您希望与其激活的声明性绑定一起使用的视图模型对象。rootNode(可选)—要在其中搜索数据绑定属性的文档部分。
换句话说,viewModel是一个对象,必须被提供,而rootNode是一个HTMLElement,是可选的。清单 9-7 中显示了更新后的Knockout界面,其中包含了额外的类型信息。
interface Knockout {
applyBindings(viewModel: {}, rootNode?: HTMLElement): void;
computed: any;
observable: any;
observableArray: any;
}
declare var ko: Knockout;
Listing 9-7.’applyBindings’ definition
Note
即使您还不知道库中函数或对象的确切签名,将类型限制为一般的Function或Object类型将防止许多可能的错误,例如简单类型的传递。
这个更新的类型定义提供了更全面的类型检查,确保至少有一个参数被传递给applyBindings,并且所有传递的参数都是正确的类型。它还允许开发工具提供有用的类型提示和自动完成,如图 9-6 所示。
图 9-6。
Autocompletion for th e applyBindings method
另一种扩展类型信息的技术是提供一个签名,您可以从自己对库的使用中推断出这个签名。应用中的两个实例ko.computed都被传递了一个执行计算的函数。您可以更新类型定义来表明computed方法期望提供一个函数,如清单 9-8 所示。
如果求值器的返回类型是固定的,可以在括号内的类型定义中指定。同样,如果需要使用从computed方法返回的值,可以更新括号外的返回类型,以提供返回类型的详细信息。
interface Knockout {
applyBindings(viewModel: any, rootNode?: any): void;
computed: (evaluator: () => any) => any;
observable: any;
observableArray: any;
}
declare var ko: Knockout;
Listing 9-8.‘computed’ definition
您可以使用官方文档或通过基于示例推断类型来继续扩展定义,以创建清单 9-9 中所示的Knockout接口。这既有一级类型信息,也有二级类型信息。
interface Knockout {
applyBindings(viewModel: {}, rootNode?: HTMLElement): void;
computed: (evaluator: () => any) => any;
observable: (value: any) => any;
observableArray: (value: any[]) => any;
}
declare var ko: Knockout;
Listing 9-9.Complete second-level definition
要完成类型定义,您需要重复将每次使用的any转换成更详细的类型的过程,直到您不再依赖于隐藏动态类型的细节。每当一个定义扩展到不可管理的大小时,您可以使用一个额外的接口来划分它,以帮助限制定义中任何特定部分的复杂性。
清单 9-10 通过将applyBindings方法的细节转移到一个单独的KnockoutApplyBindings接口中,展示了“分而治之”的技术。然后在Knockout接口中使用它将类型信息绑定到方法。
interface KnockoutApplyBindings {
(viewModel: {}, rootNode?: HTMLElement): void;
}
interface Knockout {
applyBindings: KnockoutApplyBindings;
computed: (evaluator: () => any) => any;
observable: (value: any) => any;
observableArray: (value: any[]) => any;
}
declare var ko: Knockout;
Listing 9-10.Dividing type definitions into interfaces
尽管这个 Knockout 的类型定义还远未完成,但它涵盖了运行示例应用所需的所有 Knockout 特性。您可以根据需要添加更多类型的信息,只有在获得合理回报时才进行投资。
转换 JavaScript 应用
如果您有一个现有的 JavaScript 应用,并且正在切换到 TypeScript,那么有三种潜在的策略来处理您的旧代码:
- 编写类型定义
- 将您的 JavaScript 文件添加到编译中
- 将 JavaScript 代码添加到 TypeScript 文件中
为您自己的 JavaScript 代码编写类型定义是最不理想的解决方案,因为您将重复或浪费大量的精力。允许编译器包含您的 JavaScript 代码允许您开始将代码迁移到 TypeScript 的过程;并将它移动到 TypeScript 文件中就完成了这个过程。
为了说明迁移 JavaScript 代码的过程,我们将使用清单 9-11 中所示的非常基本的 JavaScript 文件。这个文件包含一些我们想在新的 TypeScript 程序中使用的任意的旧处理代码。
function old_process(name) {
return name + ' processed';
}
Listing 9-11.An old JavaScript file
调用旧 JavaScript 的新代码如清单 9-12 所示。
class NewProcessor {
process(name: string) {
return old_process(name);
}
}
Listing 9-12.The new code
当您尝试编译 TypeScript 文件时,将会看到错误“mynewlib.ts(3,16):错误 TS2304:找不到名称“old_process”这是因为编译器看不到旧代码。
tsc mynewlib.ts --outDir ./dist
Listing 9-13.Compilation of just the new code
在编写类型定义或试图将旧库升级到 TypeScript 之前,可以使用包含的 JavaScript 重新编译。在编译中包含 JavaScript 文件时,最好将输出重定向到一个单独的文件夹,因为 JavaScript 输入文件不能被覆盖。清单 9-14 显示了允许编译 JavaScript 文件的编译器命令,并在编译中包括旧的 JavaScript 文件和新的 TypeScript 文件。
tsc myoldlib.js mynewlib.ts --allowJs --outDir ./dist
Listing 9-14.Compilation including JavaScript
通过在编译中包含 JavaScript 文件,编译器可以在许多情况下推断类型信息,现在编译器错误已经消失了。
您可以进一步改进这一点,如清单 9-15 所示,其中 JavaScript 依赖关系在 TypeScript 文件中是显式的。
///<reference path="myoldlib.js" />
class NewProcessor {
process(name: string) {
return old_process(name);
}
}
Listing 9-15.JavaScript dependency
有了引用注释,您不再需要在编译中指定 JavaScript 文件。此外,您的编辑器现在将提供推断的类型信息作为自动完成提示。
tsc mynewlib.ts --allowJs --outDir ./dist
Listing 9-16.TypeScript and JavaScript compilation with reference comment
为了进一步改善开发人员的体验,您可以开始将您的 JavaScript 移动到 TypeScript 文件中,这允许您添加类型注释来改进类型信息,并在编译器无法理解类型的地方为其提供帮助。如果您的 JavaScript 代码特别晦涩难懂,编译器可能无法推断出许多类型。
如果有大量 JavaScript 文件需要升级,可以先将低级依赖项升级到 TypeScript,而依赖于它们的其他 JavaScript 文件继续引用 TypeScript 文件的编译输出。在运行时,只要只添加类型注释而不重新构造程序,文件最初是用 TypeScript 还是 JavaScript 编写的都没有区别。
最好在用 TypeScript 编写整个程序之前保存任何重构工作,因为对 TypeScript 的重构支持更加智能。
摘要
几乎每一个流行的 JavaScript 库都已经有一个类型定义列在 NPM 的类型组织上,但是如果你遇到一个更奇特的库或者一个全新的库没有列出,你可以创建你自己的类型定义。使用迭代/增量方法编写类型定义可以让您获得投入的时间和精力的最佳回报,并且您可以使用库的文档来查找类型信息或通过阅读示例来推断它。
您可以使用创建类型定义的相同技术来重用您自己的 JavaScript 代码,但是简单地将您的 JavaScript 移动到一个 TypeScript 文件中并添加编译器无法为您推断的任何类型注释可能会花费较少的时间。
无论您是编写类型定义还是将 JavaScript 升级到 TypeScript,编译器都可能会发现您从来不知道存在的错误——您可能会对以前遗漏的内容感到惊讶。
要点
- 类型定义通常放在扩展名为
.d.ts的文件中。 - 您可以增量地创建新的类型定义——您不需要花费时间一次为整个库生成类型信息。
- 您可以在 TypeScript 编译中包含 JavaScript 文件。
- 将文件从 JavaScript 升级到 TypeScript 通常比创建类型定义文件更容易。
- 因为 JavaScript 完全是动态的,所以当您升级到 TypeScript 时,您可能会发现并修复您不知道存在的错误。
十、自动化测试
我对任何领域的专家的定义是一个对真正发生的事情有足够了解的人。—P. J .普洛伊格
对于任何编写大规模应用的人来说,自动化测试都是一个重要的话题。通过自动化程序测试,开发人员可以花更多的时间在新特性上,花更少的时间修复缺陷。自动化测试对于重构也是必不可少的。没有任何一种测试方法能够独自提供足够高的缺陷检测率。这意味着在软件发布之前,需要几种测试的组合来检测合理数量的问题。
这可能令人惊讶,但是经验证据表明,对于不同种类的测试,您将实现以下缺陷检测率,如 Steve McConnell 在 Code Complete (Microsoft Press,2004)中所记录的:
- 单元测试可以检测出高达 50%的缺陷。
- 集成测试可以检测高达 40%的缺陷。
- 回归测试可以检测多达 30%的缺陷。
这些数字表明,随着测试在软件开发生命周期的后期进行,更多的缺陷会漏网。众所周知,缺陷越晚被发现,成本越高。记住这一点,也许测试优先编程提供了减少 bug 的最有效的方法之一(还有结对编程,因为协作工作方法已经被发现比任何类型的测试检测到更多的缺陷)。测试驱动设计(TDD)的支持者也会很快指出,测试是一种额外的东西,而不是 TDD 的主要目的,TDD 是一种帮助设计内聚的代码单元的工具。他们可能是对的,但是测试也很好!
本章的目的不是让你转向测试驱动的设计。无论您是选择在编码前编写测试,还是在编写完程序的一部分后编写测试,或者希望自动化测试而不是手动执行测试,本章中的信息都是有用的。
Note
缩写词 TDD 最初是为测试驱动开发创造的,但是对测试驱动设计的修改描述向这种实践在帮助形成程序设计中所扮演的角色致敬。
框架选择
有许多用 JavaScript 编写的高质量测试框架可以用来测试你的程序。这里列出了三个最流行的,但是还有很多没有列出来,你甚至不需要使用框架,因为测试也可以在普通的类型脚本代码中进行。
- 茉莉
- 摩卡
- 玩笑
本章中的例子是使用 Jest 编写的,Jest 是一个易于设置的测试框架,由于它与 React 框架的紧密联系,已经获得了一些关注。
示例中显示的代码涵盖了 FizzBuzz 编码形的前几个步骤。编码形是一种实践方法,涉及解决一个简单的问题,逐渐适应挑战你的设计。附录 4 解释了编码表。FizzBuzz 形是基于一种由一系列计数规则组成的儿童游戏。当你表演形时,你的目标是通过游戏中的下一个规则;避免提前思考的诱惑。随着您编写更多的代码,设计将会浮现出来,您可以重构您的程序(安全地知道,如果您的测试通过,您不会意外地改变行为)。
开玩笑的测试
Jest 是为了补充 React 框架而编写的,但它可以用来测试任何类型脚本或 JavaScript 程序。运行 Jest 最常见的方式是通过 Node。语法很简单,容易学习,过去用过 Jasmine 的人都很熟悉。
安装 Jest
将 Jest 添加到项目中的最简单方法是从 NPM 获取包和类型定义。您可以通过将这些包添加到您的开发依赖项中来做到这一点,如清单 10-1 所示。您还会注意到,我们使用 package.json 文件来告诉 Node 哪个框架将处理我们的测试运行。
{
"name": "fizzbuzz",
"version": "1.0.0",
"devDependencies": {
"@types/jest": "²¹.1.0",
"jest": "²¹.1.0"
},
"scripts": {
"test": "jest"
}
}
Listing 10-1.Package dependencies
一旦下载了这些包,就可以开始编写代码了。
第一个规范
将被测试的 FizzBuzz 类的一个简单实现如清单 10-2 所示。这个类的目的是在 FizzBuzz 游戏中给出一个数字时提供一个正确的答案。完整的实现将通过返回所玩的数字或者用诸如“Fizz”、“Buzz”或“FizzBuzz”之类的游戏词来替换该数字来做出响应,这取决于该数字是能被 3、5 还是 3 和 5 整除
Note
FizzBuzz 游戏通常是集体玩的。每个人依次从 1 开始说出下一个数字。如果这个数能被三整除,玩家应该说“嘶嘶”而不是这个数。如果这个数字能被 5 整除,玩家应该说“嗡嗡”,如果这个数字能被 3 和 5 整除,玩家应该说“嘶嘶嗡嗡”
规范被用来驱动编程任务,而不是一次实现所有这些逻辑。因此,该类在实现任何超出初始实现的行为之前等待 Jest 规范,该行为总是返回数字 1。
export class FizzBuzz {
generate(input: number) {
return 1;
}
}
Listing 10-2.FizzBuzz code in FizzBuzz.ts
清单 10-3 中显示了与此行为匹配的 Jest 测试。这个测试代表了你第一次向某人解释 FizzBuzz 的规则时,你和他对话中的第一句话。例如,“播放数字 1 时,您应该说‘1’。”
import { FizzBuzz } from './FizzBuzz';
describe('A FizzBuzz generator', () => {
it('should return the number 1 when 1 is played', () => {
const fizzBuzz = new FizzBuzz();
const result = fizzBuzz.generate(1);
expect(result).toBe(1);
});
});
Listing 10-3.Jest test in FizzBuzz.test.ts
describe方法接受一套规范和一个将测试每一个规范的函数的名称。it方法代表一个单一的规范。套件和规范中使用的语言旨在让人们能够读懂。在这种情况下,组合套件描述和规格说明文本如下,
When playing 1, the FizzBuzz generator should return the number 1.
通过仔细选择规范中的语言,您可以从您的测试套件中获得免费的文档。你甚至可以想出一个更好的描述方式,用更人性化的语言来描述这种行为。如果是这种情况,您应该更改描述以匹配您改进的措辞。在这些细节上花点心思是值得的,因为从长远来看,这会使规范更有价值。
通过实例化FizzBuzz类、播放数字 1 并检查结果是否为 1,传递到规范中的函数与该声明相匹配。
Jest 在名为*.test.js 的文件中查找测试,不包括 node_modules 文件夹中的文件,因此命名很重要。如果您忘记了这一点,那么当您运行 Jest 时将找不到任何测试。
要运行 Jest,只需在项目文件夹中运行清单 10-4 中所示的命令。
npm test
Listing 10-4.Running Jest
该命令的输出如图 10-1 所示。
图 10-1。
The Jest test result
现在你已经做好了一切准备,可以开始测试了。接下来,您将使用额外的测试来驱动 FizzBuzz 程序的实现。
推动实施
现在测试自动化已经就绪,可以使用新的规范来驱动实现了。清单 10-5 显示了在 FizzBuzz 游戏中玩数字 2 时预期行为的第二个规范。
import { FizzBuzz } from './FizzBuzz';
describe('A FizzBuzz generator', () => {
it('should return the number 1 when 1 is played', () => {
const fizzBuzz = new FizzBuzz();
const result = fizzBuzz.generate(1);
expect(result).toBe(1);
});
it('should return the number 2 when 2 is played', () => {
const fizzBuzz = new FizzBuzz();
const result = fizzBuzz.generate(2);
expect(result).toBe(2);
});
});
Listing 10-5.Extending the specification
第二个规范将失败,因为 FizzBuzz 类被硬编码为无论播放哪个值都返回“1”。运行测试的结果如图 10-2 所示。
图 10-2。
The failing test result
失败消息表明测试“Expected value to be: 2, Received: 1,”这意味着 Jest 没有通过测试,因为返回了“1”,而预期的是“2”。
为了通过测试,必须更新FizzBuzz类,如清单 10-6 所示。返回输入的任何数字都将通过两个现有规范。虽然您可能知道您将很快添加更多的规范,而这些规范将不会包含在这个实现中,但是在编写代码之前等待一个失败的测试可以确保每个变体的测试都已编写完成,并且在您编写导致它们通过的代码之前失败。知道如果行为不正确,测试将会失败,这将给你以后重构程序的信心。
export class FizzBuzz {
generate(input: number) {
return input;
}
}
Listing 10-6.Updated FizzBuzz class
当您在此更改后重新运行规范时,所有规范都将通过。结果如图 10-3 所示。
图 10-3。
The passing test suite
清单 10-7 展示了驱动FizzBuzz类实现的下一个规范。这个规范要求在播放数字三的时候,要用数字三代替“嘶嘶”这个词。
it('should return "Fizz" when 3 is played', () => {
const fizzBuzz = new FizzBuzz();
const result = fizzBuzz.generate(3);
expect(result).toBe('Fizz');
});
Listing 10-7.The Fizz specification
在首先检查规范失败后,您可以更新清单 10-8 中所示的实现。这个更新也是通过测试的最简单的代码。
class FizzBuzz {
generate(input: number) : string | number {
if (input === 3) {
return 'Fizz';
}
return input;
}
}
Listing 10-8.The updated
FizzBuzz class
此阶段运行规范的结果如图 10-4 所示。阅读测试输出,感受一下测试如何用业务领域的语言描述程序。
图 10-4。
The passing test suite of three tests
重构
既然已经编写了大量的规范,并且实现了通过这些规范的代码,那么重构程序是值得的。重构代码包括改变程序的结构和设计,而不改变行为。知道你真的在重构(而不是无意中改变程序的实际功能)的最简单的方法是进行自动化测试,突出任何偶然的改变。
同样值得强调的是,你的测试代码应该和你的产品代码一样好写和可维护,但是不那么抽象。出于这个原因,清单 10-9 展示了重构后的规范,其中 FizzBuzz 类的重复实例化被移到了一个beforeEach方法中,Jest 将在每个规范之前自动运行。
import { FizzBuzz } from './FizzBuzz';
describe('A FizzBuzz generator', () => {
let fizzBuzz: FizzBuzz;
beforeEach(() => {
fizzBuzz = new FizzBuzz();
});
it('should return the number 1 when 1 is played', () => {
const result = fizzBuzz.generate(1);
expect(result).toBe(1);
});
it('should return the number 2 when 2 is played', () => {
const result = fizzBuzz.generate(2);
expect(result).toBe(2);
});
it('should return "Fizz" when 3 is played', () => {
const result = fizzBuzz.generate(3);
expect(result).toBe('Fizz');
});
});
Listing 10-9.Refactored specifications
无论何时重构代码,都应该重新运行所有的测试,以确保没有改变程序的行为。如果您的测试套件失败了,您可以简单地撤销您的更改并重新开始,而不是调试程序。测试运行之间的每一次迭代都应该小到足以丢弃。
export class FizzBuzz {
generate(input: number): string | number {
let output = '';
if (input % 3 === 0) {
output += 'Fizz';
}
if (input % 5 === 0) {
output += 'Buzz';
}
return output === '' ? input : output;
}
}
Listing 10-10.A working FizzBuzz class using conditional statements
清单 10-10 中的代码展示了FizzBuzz类的一个工作版本,它涵盖了返回一个数字的默认规则以及Fizz、Buzz和FizzBuzz的三种变体。在这一点上,尽管generate方法仍然很短,但是可以从代码中看到替代的设计。特别是,随着新规则的增加(可能被 7 整除的数字应该返回‘Bazz’),您可能会决定引入和修改一个设计模式来捕获特定的规则。
Note
FizzBuzz 编码形式通常用一种叫做责任链的设计模式来解决,尽管还有其他可能的解决方案。
清单 10-11 中显示了为推动这一实现而创建的规范。现在总共有八种规格来涵盖四种可能的响应。
import { FizzBuzz } from './FizzBuzz';
describe('A FizzBuzz generator', () => {
let fizzBuzz: FizzBuzz;
const FIZZ = 'Fizz';
const BUZZ = 'Buzz'
const FIZZ_BUZZ = 'FizzBuzz';
beforeEach(() => {
fizzBuzz = new FizzBuzz();
});
it('should return the number 1 when 1 is played', () => {
const result = fizzBuzz.generate(1);
expect(result).toBe(1);
});
it('should return the number 2 when 2 is played', () => {
const result = fizzBuzz.generate(2);
expect(result).toBe(2);
});
it('should return "Fizz" when 3 is played', () => {
const result = fizzBuzz.generate(3);
expect(result).toBe(FIZZ);
});
it('should return "Fizz" when 6 is played', () => {
const result = fizzBuzz.generate(6);
expect(result).toBe(FIZZ);
});
it('should return "Buzz" when 5 is played', () => {
const result = fizzBuzz.generate(5);
expect(result).toBe(BUZZ);
});
it('should return "Buzz" when 10 is played', () => {
const result = fizzBuzz.generate(10);
expect(result).toBe(BUZZ);
});
it('should return "FizzBuzz" when 15 is played', () => {
const result = fizzBuzz.generate(15);
expect(result).toBe(FIZZ_BUZZ);
});
it('should return "FizzBuzz" when 30 is played', () => {
const result = fizzBuzz.generate(30);
expect(result).toBe(FIZZ_BUZZ);
});
});
Listing 10-11.The specifications for the working FizzBuzz class
除了测试 FizzBuzz 类,这些规范还为程序提供了准确的文档。输出如图 10-5 所示。您可能会注意到 Jest 已经标记了其中一个测试的执行时间(15 ms),这有助于您识别任何降低测试套件速度的测试。
图 10-5。
The passing test suite of three tests
这些测试是可执行规范的一种形式——一种活的文档形式,也证明了你的程序执行了文档记录的行为。
隔离依赖关系
有时你可能需要测试依赖于资源的部分代码,这使得你的测试变得脆弱。例如,它可能依赖于第三方 API 或处于特定状态的数据库。如果您需要在不依赖这些依赖项的情况下测试代码,您可以在使用本节中描述的技术进行测试时隔离它们。
在许多编程语言中,每当您需要提供一个测试 double 时,使用一个模仿框架已经变得很自然。然而,在 TypeScript 中,创建测试 doubles 非常简单,您可能永远都不需要搜索框架。
清单 10-12 显示了依赖于localStorage的FizzBuzz类的修改版本,它在 TypeScript 中实现了Storage接口。constructor接收存储对象,generate函数使用它来获取在“嘶嘶”情况下显示的显示消息。
class FizzBuzz {
constructor(private storage: Storage) {
}
generate(input: number): string | number {
if (input === 3) {
return this.storage.getItem('FizzText');
}
return input;
}
}
Listing 10-12.A FizzBuzz class that relies on storage
您可以用清单 10-13 中所示的简单对象来满足这种依赖性。storage变量与Storage接口的匹配刚好足以通过测试。与其他编程语言不同,这种解决测试 double 问题的方法非常简单;你几乎不需要考虑用一个框架来解决问题。
describe('A FizzBuzz generator', () => {
it('should return "FakeFizz" when 3 is played', () => {
// Create a test double for storage
var storage: any = {
getItem: () => 'FakeFizz'
};
const fizzBuzz = new FizzBuzz(storage);
const result = fizzBuzz.generate(3);
expect(result).toBe('FakeFizz');
});
});
Listing 10-13.Using an object
总的来说,您应该坚持使用简单的对象作为测试替身,并且您的测试应该检查结果,而不是具体的实现细节。知道当你玩 3 时你得到“Fizz”是一个很强的行为测试,但是检查一个storage对象是否被调用来提供一个匹配特定键的值根本不是一个好的测试,因为当你改变实现细节时这将失败。
摘要
希望自动化测试的价值已经在这一章得到了展示。然而,如果你仍然持怀疑态度,你可以尝试在有测试和没有测试的情况下运行编码卡塔,看看它是否有助于你下定决心。您可以在附录 4 中了解更多信息。
尽管本章所有的例子都使用了 Jest,但是使用 Mocha 或 Jasmine 也同样简单,并且两者都提供了同样简单的语法。无论您使用什么来运行测试,都要努力使输出看起来像人类可读的文档,因此如果有人需要文档,您可以简单地提供您的测试套件的输出。
我已经为 TypeSpec 的 TypeScript 创建了一个基于 Gherkin 语言的行为驱动框架的实现,您可以使用它来结合业务规范和测试自动化,但是我在本章中使用 Jest,因为我更希望程序员从更常用的工具开始。您可以在 GitHub 上找到更多关于 TypeSpec 的信息:
https://github.com/Steve-Fenton/TypeSpec
要点
- 自动化单元测试比集成测试或回归测试更有效(尽管一个好的策略是使用多种测试来获得最佳的缺陷检测率)。
- 有很多 JavaScript 和 TypeScript 的框架,但是如果你想缩小范围,可以看看 Jest、Jasmine 和 Mocha。
- 您可以使用 Jest 编写充当测试和文档的规范。
- 用规范驱动实现可以确保如果行为不正确,测试会失败。在实现之后编写测试并不保证测试会失败。
- 您应该重构生产代码和测试代码。
- 您可以使用简单的对象来隔离您的依赖项,这比那些可能将您的测试与实现捆绑得太紧的智能工具更好。