Часто сервера на Node.JS используются как сервисы-агрегаторы, получающие динамические данные с других HTTP-источников и формирующие на основе этих данных агрегированный ответ.

Для обработки полученных данных удобно использовать внешние процессы, обрабатывающие исходный набор файлов (например, утилиты ImageMagick или ffmpeg).

Рассмотрим это на примере HTTP-сервера, выполняющего роль backend для сервера nginx, и формирующего CSS-спрайты для набора изображений.

Асинхронные чтение/запись


Пул клиентских соединений

Объекты «HTTP-клиент» в Node.JS работают каждый с одним TCP-соединением, выполняя запросы по очереди, поэтому нам нужно организовать пул клиентов (компромисс между созданием соединений на каждый чих и использованием одного соединения), если мы хотим работать действительно быстро (параллельно).

Самый примитивный пул мы сделаем из предположения, что все исходные запросы мы посылаем на адрес example.com:80.

var ClientPool = function()
{
  this.poolSize = 0;
  this.freeClients = [];
};

ClientPool.prototype.needClient = function()
{
  this.freeClients.push(this.newClient());
  this.poolSize++;
};

ClientPool.prototype.newClient = function()
{
  return http.createClient(80, 'example.com');
};

ClientPool.prototype.request = function(method, url, headers)
{
  if (this.freeClients.length == 0)
  {
    this.needClient();
  }
  var client = this.freeClients.pop();
  var req = client.request(method, url, headers);
  return req;
};

ClientPool.prototype.returnToPool = function(client)
{
  this.freeClients.push(client);
};

var clientPool = new ClientPool();

* This source code was highlighted with Source Code Highlighter.


При желании можно изменить архитектуру пула, разрешив соединения к нескольким хостам, а также ограничив сверху размер пула (при этом раскидывая запросы по наименее загруженным соединениям). Оставлю это в качестве домашнего задания.

Получение и сохранение файла

Нам нужна асинхронная функция для выполнения HTTP-запросов и сохранения содержимого в файл. Особенность её в том, что выполняется сразу два потока асинхронных операций — чтение исходного HTTP-потока, и запись в файл. Причём успешно закрыть файл и вызвать функцию обратного вызова мы можем только по завершению всех операций записи, которые могут выполняться не обязательно последовательно.

Вот пример реализации:

var getFile = function(url, path, callback)
{
  fs.open(path, 'w', 0600, function(err, fd)
  {
    if (err)
    {
      callback(err);
      return;
    }
    var request = clientPool.request('GET', url, { 'Host': 'example.com' });
    request.on('response', function(sourceResponse)
    {
      var statusCode = parseInt(sourceResponse.statusCode);
      if (statusCode < 200 || statusCode > 299)
      {
        sourceResponse.on('end', function()
        {
          clientPool.returnToPool(sourceResponse.client);
        });
        callback('Bad status code');
        return;
      }

      var writeErr = null;
      var writesPending = 0;
      var sourceEnded = false;

      var checkPendingCallback = function()
      {
        if (!sourceEnded || writesPending > 0)
        {
          return;
        }
        fs.close(fd, function(err)
        {
          err = err ? err : writeErr;
          if (err)
          {
            removeFile(path);
            callback(err);
            return;
          }
          // No errors and all written
          callback(null);
        });
      };

      var position = 0;
      sourceResponse.on('data', function(chunk)
      {
        writesPending++;
        fs.write(fd, chunk, 0, chunk.length, position, function(err, written)
        {
          writesPending--;
          if (err)
          {
            writeErr = err;
          }
          checkPendingCallback();
        });
        position += chunk.length;
      });

      sourceResponse.on('end', function()
      {
        sourceEnded = true;
        checkPendingCallback();
        clientPool.returnToPool(sourceResponse.client);
      });
    });
    request.end();
  });
};


* This source code was highlighted with Source Code Highlighter.


Механизм взаимодействия nginx и нашего сервера



Для того, чтобы не генерировать спрайты на каждый запрос, мы будем сохранять выходные спрайты, удаляя самые старые из их, например, по крону. В случае, если файл уже существует, nginx отдаст его по правилу try_files. В противном случае запрос будет перенаправлен на наш backend, который создаст нужный файл, и с помощью X-Accel-Redirect попросит nginx отдать файл с внутренней локации, которая ведёт на то же физическое пространство.

При этом конфигурация nginx будет выглядеть где-то так:
    upstream sprite_gen {
        server 127.0.0.1:14239;
    }

    location /out_folder/ {
        alias /var/sprite-gen/out_folder/;
        internal;
    }

    location / {
        alias /var/sprite-gen/out_folder/;
        try_files $uri @transcoder;
    }

    location @transcoder {
        proxy_pass  http://sprite_gen;
    }


Данный пример не претендует на совершенство, с его помощью хорошо отдавать большие файлы, в том числе и по частям, с кэшированием.

Если же файлы маленькие, и нам желательно лучше контролировать перегенерацию спрай��ов с отсутствующими картинками, то правильнее кэшировать на стороне nginx с правилом вида proxy_no_cache $http_pragma.

Получаем несколько файлов



Здесь фрагмент HTTP-сервера, отвечающего за получение набора файлов, формирование спрайта и отдачу результата для nginx.

  var outPath = ''// Куда кладём результирующий спрайт
  var imageUrls = []; // Здесь список путей для исходных изображений.
  var images  = []; // Здесь список путей для исходных изображений.

  var waitCounter = images.length;
  var needCache  = true; // если хоть одно изображение отсутствует, и заменено плейсхолдером, то выключаем кэширование
  var handlePart = function(url, pth)
  {
    getFile(url, pth, function(err)
    {
      waitCounter--;
      if (err)
      {
        removeFile(pth);
        var pth = placeholder_path;
        needCache = false;
      }
      if (waitCounter == 0)
      {
        makeSprite(images, outPath, function(err)
        {
          if (err)
          {
            response.writeHead(500, {
              'Content-Type':   'text/plain',
            });
            response.end('Trouble');
            return;
          }
          var headers = {
            'Content-Type':   'image/png',
            'X-Accel-Redirect': outUrl
          };
          if (needCache)
          {
            headers['Cache-Control'] = 'max-age:315360000, public';
            headers['Expires'] = 'Thu, 31 Dec 2037 23:55:55 GMT';
          }
          else
          {
            headers['Cache-Control'] = 'no-cache, no-store';
            headers['Pragma'] = 'no-cache';
          }
          response.writeHead(200, headers);
          response.end();
        });
      }
    });
  };

  for (var i = 0; i < imageUrls.length)
  {
    handlePart(imageUrls[i], images[i]);
  }

* This source code was highlighted with Source Code Highlighter.


Формируем выходной файл посредством внешнего процесса


Контролировать внешние процессы с помощью Node.JS легко и удобно. Для удобства отладки будем копировать вывод, генерируемый внешним процессом, в нашу консоль. Для формирования спрайта выберем пакет GraphicsMagick (форк ImageMagick, обладающий стабильным API и хорошей производительностью).

var spriteScript = '/usr/bin/gm';
var placeholder = path.join(__dirname, 'placeholder.jpg');

var getParams = function(count)
{
  return ('montage +frame +shadow +label -background #000000 -tile ' + count + 'x1 -geometry +0+0').split(' ');
};

var removeFile = function(path)
{
  fs.unlink(path, function(err)
  {
    if (err)
    {
      console.log('Cannot remove ' + path);
    }
  });
};

var cleanup = function(inPaths, placeholder)
{
  for (var i = 0; i < inPaths.length; i++)
  {
    if (inPaths[i] == placeholder)
    {
      continue;
    }
    removeFile(inPaths[i]);
  }
};

var makeSprite = function(inPaths, outPath, placeholder, callback)
{
  var para   = getParams(inPaths.length).concat(inPaths, outPath);
  console.log(['run', spriteScript, para.join(' ')].join(' '));
  var spriter = child_process.spawn(spriteScript, para);

  spriter.stderr.addListener('data', function(data)
  {
    console.log(data);
  });
  spriter.stdout.addListener('data', function(data)
  {
    console.log(data);
  });
  spriter.addListener('exit', function(code, signal)
  {
    if (signal != null)
    {
      callback('Internal Server Error - Interrupted by signal' + signal.toString());
      return;
    }
    if (code != 0)
    {
      callback('Internal Server Error - Code is ' + code.toString());
      return;
    }
    cleanup(inPaths, placeholder);
    callback(null);
  });
};

* This source code was highlighted with Source Code Highlighter.


Небольшие нюансы


Формируем имя для временного файла

Для формирования имени файла лучше использовать Process.pid и счётчик запросов (например, как path.join('/tmp', ['source-file', Process.pid, requestCounter].join('-')). При этом функция обработки запросов должна получать счётчик запросов как аргумент, так как обработка следующего за��роса может начаться раньше, чем выполнятся все шаги выполнения текущего запроса.

Чистим временные данные от прошлых процессов

Пусть все наши временные файлы имеют имя source-pid… или sprite-pid-…:

var fileExpr   = /^(?:source|sprite)\-(\d+)\b/;
var storagePath = '/tmp/';

var cleanupOldFiles = function()
{
    fs.readdir(storagePath, function(err, files)
    {
        if (err)
        {
            console.log('Cannot read ' + storagePath + ' directory.';
            return;
        }
        for (var i = 0; i < files.length; i++)
        {
            var fn = files[i];
            m = fileExpr.exec(fn);
            if (!m)
            {
                continue;
            }
            var pid = parseInt(m[1]);
            if (pid == process.pid)
            {
                continue;
            }
            removeFile(path.join(storagePath, fn));
        }
    });
};

* This source code was highlighted with Source Code Highlighter.


Скелет обработки запроса


Пусть мы хотим получить спрайт по фотоальбому с какого-то момента времени (timespec).

#!/usr/bin/env node

var child_process = require('child_process');
var http     = require('http');
var path     = require('path');
var fs      = require('fs');

var routeExpr   = /^\/?(\w)\/([^\/]+)\/(\d+)\/(\d+)x(\d+)\.png$/;
var fileCounter = 0;

http.createServer(function(request, response)
{
    if (request.method != 'GET')
    {
        response.writeHead(405, {'Content-Type': 'text/plain'});
        response.end('Method Not Allowed');
        return;
    }
    var m = routeExpr.exec(request.url);
    if (!m)
    {
        response.writeHead(400, {'Content-Type': 'text/plain'});
        response.end('Bad Request');
        return;
    }

    var mode   = m[1];
    var chapter = m[2];
    var timespec = parseInt(m[3]);
    var width  = parseInt(m[4]);
    var height  = parseInt(m[5]);

    fileCounter++;
    var moments = [timespec];
    addWantedMoments(moments, mode)

    var runner = function(moments, fileCounter, width, height)
    {
        var waitCounter = moments.length;
        var outPath   = path.join(storagePath, ['sprite', process.pid, fileCounter].join('-') + '.png');
        var needCache  = true;

        for (var i = 0; i < moments.length; i++)
        {
            handlePart(i, placeholder);
        }
    };
    request.connection.setTimeout(0);
    runner([].concat(moments), fileCounter, width, height);

}).listen(8080, '127.0.0.1');

console.log('Server running at 127.0.0.1:8080');

cleanupOldFiles();

* This source code was highlighted with Source Code Highlighter.


Собственно, теперь у нас есть готовое приложение, формирующее спрайт, как агрегированный результат по набору запросов к другим сайтам.

Осталось добавить конкретики (алгоритмы получения ссылок на исходные изображения, формирования плэйсхолдеров, если размеры постоянно меняются), и этим можно пользоваться.

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