Pull to refresh

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

Reading time13 min
Views4.5K
Часто сервера на 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.


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

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

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

Tags:
Hubs:
+5
Comments1

Articles