Два патча для rewire и как работает загрузчик модулей nodejs

Всем привет!

Если вам всегда было любопытно, почему в nodejs функция require может вычислять
относительные пути относительно текущего файла, н-р require("../../lib");, добро пожаловать.

Недавно я описывал список правил для юниттестов на nodejs. И упомянул библиотеку rewire.

Во фреймворке glaceJS, который я развиваю на досуге, я предпочитаю указывать наиболее используемые функции и модули, такие как sinon и chai.expect, как глобальные, чтобы не заморачиваться каждый раз с их импортом.

Также я поступил и с rewire, тем более что в nodejs дефолтная функция загрузки require глобальная.

Вот тут меня поджидал неожиданный момент: в тесте код rewire("../../lib/config") упорно не хотел правильно резолвить относительный путь до модуля. Но если перед этим я явно импортировал var rewire = require("rewire"); внутри теста, то все работало замечательно. Стало понятно, что это последствия того, что rewire на самом деле импортится где-то в глубине фреймворка.

Заинтересовавшись сим прелюбопытным фактом, я отправился изучать исходники rewire и nodejs.
В rewire скрипт падал в rewire/rewire.js at master · jhnns/rewire · GitHub, т.к. nodejs кидал ошибку из node/module.js at main · nodejs/node · GitHub.
Развесив дебажные месседжы console.log я выявил первопричину, а заодно разобрался, как же у require получается правильно резолвить относительные пути относительно файла, где она вызывается.

При вызове require в функцию Module._resolveFilename попадает объект parent, представляющий собой nodejs-ый module.
У parent есть parent.paths (можете в коде любого своего скрипта вызвать module.paths и module.parent.paths и узнать, что это за пути такие), через которые идет попытка разрезовлить относительный путь до модуля.
При запуске nodejs-скрипта в коде доступен объект module, у которого есть module.parent и module.parent.parent и т.д. За тем, чтобы при вызове require был использован
правильный parent, следит сама nodejs. В случае rewire parent передается явно rewire/index.js at master · jhnns/rewire · GitHub, и его значение
будет зависеть от того, где был заимпорчен rewire.

Когда я импортил rewire где-то внутри фреймворка, а потом использовал его в другом модуле, в module.parent попадал совершенно не тот объект, относительно которого нужно было резолвить относительный путь.

Решением стала следующая идея: внутри своей функции rewire резолвить относительный путь относительно файла, где функция была вызвана, и передавать в оригинальный rewire уже абсолютный путь. К сожалению nodejs не позволяет определить путь к файлу, где была вызвана функция. Но к счастью, V8 API дает такую возможность. И даже есть проект caller-path. Но у меня он не заработал правильно, и поэтому я просто скопировал несколько строчек, которые мне были нужны, и подправил их до рабочего состояния, получив такой код:

var path = require("path");

var _rewire = require("rewire");

global.rewire = filename => {

    var _ = Error.prepareStackTrace;
	Error.prepareStackTrace = (_, stack) => stack;
	var stack = new Error().stack.slice(1);
    Error.prepareStackTrace = _;
    var callerPath = stack[0].getFileName();

    var callerDir = path.dirname(callerPath);
    filename = path.resolve(callerDir, filename);

    return _rewire(filename);
};

Теперь относительные пути определялись правильно. Однако выявилось другое неудобство, связанное уже непосредственно с rewire API, а именно с восстановлением оригинальных функций после теста.

Н-р в этом случае:

// my-module.js

var request = require("request");

exports.download = (fileUrl, filePath) => {
    request.get(fileUrl, filePath);
};

Все делается просто, и оригиальные функции восстанавливаются через sandbox.restore():

var sinon = require("sinon");

var my = rewire("./my-module");

describe("my-module", () => {
    var sandbox = sinon.createSandbox();

    afterEach(() => {
        sandbox.restore();
    });

    describe(".download()", () => {

        beforeEach(() => {
            var request = my.__get__("request");
            sandbox.stub(request, "get");
        });

        it("should download", () => {
            // some test
        });

        it ("shouldn't download", () => {
            // some test
        });
    });
});

Но в случае:

// my-module.js

var get = (fileUrl, filePath) => {
    if (!fileUrl.startsWith("https://")) {
        throw new Error("Only https allowed!");
    };
    require("request").get(fileUrl, filePath);
};

exports.download = (fileUrl, filePath) => {
    get(fileUrl, filePath);
};

Использовать sandbox.restore() не возможно, т.к. нет ссылки на неймспейс, через который можно обратиться к функции get и нельзя его застабить через sandbox (sandbox.stub(my, "get") даст ошибку, что my-module не имеет get).

Можно использовать rewire API и манипулировать с __get__, __set__ и запоминать оригинальную функцию, или же использовать rewire __with__. Но оба эти подхода выглядели очень громоздкими, а хотелось чего-то простого, как sandbox.restore(). Поэтому возникло решение, сделать дополнительный метод __reset__, который будет сбрасывать состояние модуля к оригинальному состоянию, и использоваться примерно так:

var sinon = require("sinon");

var my = rewire("./my-module");

describe("my-module", () => {

    afterEach(() => {
        my.__reset__();
    });

    describe(".download()", () => {

        beforeEach(() => {
            my.__set__("get", sinon.spy());
        });

        it("should download", () => {
            // some test
        });

        it ("shouldn't download", () => {
            // some test
        });
    });
});

И код rewire в таком случае приобрел вид:

var path = require("path");

var _rewire = require("rewire");

global.rewire = filename => {

    var _ = Error.prepareStackTrace;
	Error.prepareStackTrace = (_, stack) => stack;
	var stack = new Error().stack.slice(1);
    Error.prepareStackTrace = _;
    var callerPath = stack[0].getFileName();

    var callerDir = path.dirname(callerPath);
    filename = path.resolve(callerDir, filename);

    var mod = _rewire(filename);

    var cache = {};

    var set = mod.__set__;
    mod.__set__ = function (name, stub) {

        if (!Object.keys(cache).includes(name)) {
            cache[name] = this.__get__(name);
        };
        set.call(this, name, stub);
    };

    mod.__reset__ = function () {
        for (var [k, v] of Object.entries(cache)) {
            this.__set__(k, v);
        };
        cache = {};
    };

    return mod;
};

Конец :slight_smile:

1 лайк

Не хотите ли завести блог на том же медиуме? Я бы с удовольствием читал

1 лайк

Спасибо, хорошее предложение :slight_smile: Давно подумываю, чтобы вести блог и на английском, но было лень поддерживать какой-нибудь шаблонизатор, хотя hugo весьма неплох.

Так ведите на русском - не парьтесь ) Я вот медиум все смотрю - уже готовая площадка, ничего особо парится не нужно, а код - да хоть скриншотами. Главное начать статьи писать - а потом уже видней будет.

Вот например - техническая статья - но все прекрасно читается - AWS Lambda Go vs. Node.js performance benchmark: updated 🔥 | HackerNoon

Код просто в гистах.

1 лайк

как-то так Two patches for rewire and how nodejs require resolves relative paths | by Sergei | Medium

1 лайк