创建一个DIY的APM监视Node.js中的Web应用程序的性能

构建一个监视应用程序执行情况的工具不再是很困难了。Node.js中Async Hooks API和Performance Hooks API最近增加了两个,允许任何人只需一些代码就可以密切关注他们的应用程序的性能。这篇文章解释了构建这样一个工具的关键要素,从编写代码到以清晰的可视化报告收集的数据。

创建一个DIY的APM监视Node.js中的Web应用程序的性能

Tl;dr

构建一个监视应用程序执行情况的工具不再是很困难了。Node.js中Async Hooks API和Performance Hooks API最近增加了两个,允许任何人只需一些代码就可以密切关注他们的应用程序的性能。这篇文章解释了构建这样一个工具的关键要素,从编写代码到以清晰的可视化报告收集的数据。
最终的项目在Github上可用,并具有以下特点:

  • 一个简单的性能监控代理
  • 基于Express和MongoDB的测试应用程序

介绍

在生产中运行Web应用程序时,性能很重要。缓慢的Web服务器提供了降级的用户体验,并可能威胁整个公司的业务。
为了充分了解Web应用程序如何在生产环境中运行,负载测试是不够的。即使像ab这样的工具可以提供服务器在特定负载下应答的速度,他们也不能告诉你瓶颈在哪里。
在本文中,我们将构建一个工具来监视在一个简单的Node.js应用程序应答HTTP请求时在MongoDB中花费多少时间。
开始之前,我们先来看看这个简单的Express / Mongoose应用程:源码请到https://github.com/sqreen/funAPM/blob/master/testApp/server.js.
另外,在本文中,我们将只使用async / await语法。

首先解决方案

显而易见的解决方案就是在数据库请求周围添加时间样本并记录下来。 这可能会诀窍,但是你将不得不改变你的代码在你想要的每个方法的执行之前和之后添加一个process.hrtime或一个新的Date()。
显然,这种做法不会扩展,因此不是一个可行的解决方案。

我们来重写一些方法

如果我们不想更改应用程序代码,则需要更改其依赖项的代码。
如果我们专注于我们的应用程序的一个更小的版本:

'use strict';
const Express = require('express');
const Mongoose = require('mongoose');
const app = Express();
Mongoose.connect('mongodb://localhost/test', { useMongoClient: true });
Mongoose.Promise = global.Promise;
const Cat = Mongoose.model('Cat', { name: String });
app.get('/cats', async (req, res, next) => {
    // this route simply list the content of the 'Cat' collection
    try {
        const cats = await Cat.find().exec(); // using await syntax make it much simpler here
        return res.json(cats);
    }
    catch (e) {
        return next(e);
    }
});
app.listen(9090, () => {
    console.log('server running on port 9090');
});

我们可以通过重写Cat.find方法来监视在MongoDB中花费的时间:

const Cat = Mongoose.model('Cat', { name: String });
const CatProto = Object.getPrototypeOf(Cat); // this is the clean way to access an object's prototype. Please don't use __proto__ anymore.
const find = CatProto.find; // we take a reference on the orignial 'find method'
CatProto.find = function (...args) { // we replace the 'find' method on the prototype
    const res = find.apply(this, args); // we call the original method we overrided
    const exec = res.exec;
    res.exec = async function () { // exec is the method actually firing the request on mongoose.
        console.time('cat.find');
        try {
            const result = await exec.apply(this, arguments);
            console.timeEnd('cat.find');
            return result;
        }
        catch (err) {
            console.timeEnd('cat.find');
            throw err;
        }
    };
    return res;
};

在这个代码中:

  • 我们提取Cat对象的原型。 这是查找方法被定义的地方。
  • 我们保留对原始版本的查找的参考。
  • 我们用我们的自定义方法替换Cat原型上的find方法:
  • 使用console.time / console.timeEnd方法记录原始方法的执行时间。
  • 我们通过执行find.apply(this,arguments)来调用原始方法(这里引入apply和arguments)

当我们启动进程并在浏览器上输入http:// localhost:9090 / cats时,控制台显示:

server running on port 9090
cat.find: 1.246ms

但是,这个补丁会有几个问题:

  1. 我们不知道哪个HTTP请求负责这个调用。
  2. 执行时间只显示在控制台中,我们不存储它们,所以我们可以稍后操作它们。

Performance Hooks API

为了节省呼叫到外部服务的时间,我们将使用全新的(和实验性)Performance Hooks API。 它最近被James Snell添加到了Node.js中。 这个API符合W3C规范,因此和现代浏览器中的一样。
让我们编写一个包装函数来执行返回一个promise的函数:

const wrapAsync = function (orig, name) {
    return async function () {
        const uuid = Uuidv4();
        name = name || `mongoose.${this.op}`; // mongose Query.exec specific
        const finish = function () { // this method will be called after the wrapped method gets executed
            PerfHook.performance.measure(`${name}-${uuid}`, `start-${uuid}`, `end-${uuid}`);
            PerfHook.performance.clearMarks(`start-${uuid}`);
            PerfHook.performance.clearMarks(`end-${uuid}`);
            PerfHook.performance.clearMeasures(`${name}-${uuid}`); // we remove the marks and the measure to prevent a memory leak here.
        };
        try {
            PerfHook.performance.mark(`start-${uuid}`); // start measuring time
            const res = await orig.apply(this, arguments); // calling original method
            PerfHook.performance.mark(`end-${uuid}`);
            finish();
            return res;
        }
        catch (err) {
            PerfHook.performance.mark(`end-${uuid}`);
            finish();
            throw err;
        }
    }
};

每次调用方法时,我们都会为每个性能度量创建一个唯一的ID。这将确保两个定时操作之间不发生碰撞。
而不是直接覆盖每种方法,我们可以直接做:

const proto = Object.getPrototypeOf(Mongoose);
    const exec = proto.Query.prototype.exec;
    proto.Query.prototype.exec = wrapAsync(exec); // wrapping exec will wrap most mongoose call
    const Model = proto.Model; // we still need to wrap save and remove here since they do not work like the other methods in mongoose.
    const remove = Model.prototype.remove;
    Model.prototype.remove = wrapAsync(remove, 'mongoose.remove');
    const save = Model.prototype.save;
    Model.prototype.save = wrapAsync(save, 'mongoose.save');

Async Hooks API

Async Hooks API仍然是实验性的,但应该由Node.js 10(预计2018年4月)来稳定。这个API使我们能够在异步操作上设置钩子。
出于我们的目的,我们只需要这个API来跟踪负责代码执行的HTTP请求。一些包(如持续本地存储或区域的各种实现)提供了类似的功能。然而,由于这些模块仅基于userland代码,因此一些异步操作可能会被它们忽略, context 将会丢失(请参阅此处的示例)。
我们的钩子将会很简单:

  • 当一个异步资源被创建时,如果它的父代有一个context,这个context将被传播到新的资源。
  • 当调用destroy钩子时,我们删除资源和它的context之间的连接。
const context = new Map();
const init = function (asyncId, type, triggerAsyncId) {
    // each time a resource is init, if the parent resource was associated with a context,
    // we associate the child resource to the same context
    if (context.has(triggerAsyncId)) {
        context.set(asyncId, context.get(triggerAsyncId));
    }
};
const destroy = function (asyncId) {
    // this prevents memory leaks
    if (context.has(asyncId)) {
        context.delete(asyncId);
    }
};

然后我们把它放到一个新的Async Hook中:

'use strict';
const AsyncHooks = require('async_hooks');
process.apm = process.apm || {};
const Context = require('./context');
const instrumentation = require('./instrumentation/index');
const hook = AsyncHooks.createHook({
    init(asyncId, type, triggerAsyncId) {
        Context.init(asyncId, type, triggerAsyncId);
    },
    destroy(asyncId) {
        Context.destroy(asyncId);
    }
});
hook.enable();

现在我们需要为每个HTTP请求创建一个新的context,并提供一种从任何地方访问当前context的方法。为了跟踪HTTP请求,我们将从Node.js core覆盖类Http.Server上的emit方法:

const AsyncHooks = require('async_hooks');
const Http = require('http');
const context = new Map();
const emit = Http.Server.prototype.emit;
Http.Server.prototype.emit = function (type) {
    if (type === 'request') {
        // each time a new request arrives in the HTTP server, we associate it with the current asynchronous context
        const [req, res] = [arguments[1], arguments[2]];
        const id = AsyncHooks.executionAsyncId(); // this returns the current asynchronous context's id
        context.set(id, req);
    }
    return emit.apply(this, arguments);
};

现在,对于Http.Server的所有实例,当使用请求事件调用emit方法时,会创建一个新的context,并与当前执行的AsyncId关联。
由于我们的Async Hook会将这个context传播给子资源,因此每次调用AsyncHooks.executionAsyncId()都会返回一个有效的context映射关键字。
我们来写一个简单的方法来包装这个:

const getContext = function (asyncId = AsyncHooks.executionAsyncId()) {
    // this returns the context linked to a given asynchronous id, by default, current asynchronous id is used.
    return context.get(asyncId);
};

建立一个代理

现在,我们拥有了构建适当代理的所有工具,以便将其注入Node.js应用程序中进行监视。
我们的代理将需要成为应用程序所需的第一个模块,以放置检测钩子(稍后解释)。 它可以通过调用:

require(‘<path_to_agent>’).start();

在给你源码之前,我会分享我最后两个秘密:

  • 为了覆盖一个模块,我们可以通过改变核心中的私有方法来改变需要的行为。这不是一个好的解决方案,但目前我还不知道有什么更好的方法来实现它。(详情见https://github.com/sqreen/funAPM/blob/master/apm/instrumentation/index.js)。新的加载器钩子API只与ES模块挂钩。
  • 在node中有一个很好的选项,它允许我们在主模块之前加载模块。要利用这个选项,我们的代理将需要调用它的start方法。(详情见https://github.com/sqreen/funAPM/blob/master/apm/instrumentation/index.js)

所以我们的整个项目代理在github上可用。随意项目的核心代码,中心概念已经在本文中进行了解释。
如果你运行库中提供的testApp。将在目录中创建一个名为apm_logs.json的文件。其内容如下所示:

正如您所看到的,对于通过服务器的每个请求,代理记录了其持续时间和MongoDB操作的持续时间。为调试目的而保存的惟一标识符可以被忽略。

Bonus: Viewer

由于我们的代理正在生成JSON输出,所以我们应该能够以更加用户友好的方式显示时序数据。
使用d3.js和一个不错的时间线插件,我生成了一个网页,以更直观的方式显示代理所做的度量。在Node.js进程结束之后,会创建一个名为viewer.html的文件。

结论

在本文中,我们已经看到,构建现代Node.js应用程序的应用程序性能监视工具已经不复杂了,它使用了两个新的Node特性,Async Hooks API和Performance Hooks API。当然,这些新的API非常适合构建各种各样的好东西,作为一个Node开发者,更值得你更详细地了解它们。

结尾

商业工具的存在,并提供比我们简单的概念证明,包括更多的功能:

  1. 事件循环监视
  2. 内存监视
  3. 历史统计
  4. 垃圾收集监控
  5. 服务器负载监视

如果您在生产环境中运行Node.js应用程序,则可能需要查看它们提供的内容。

原创文章,作者:webstack,如若转载,请注明出处:https://www.webstacks.cn/tutorial/438.html

发表评论

登录后才能评论