Страницы

воскресенье, 7 июля 2013 г.

Node.js + Redis. Пишем шустрое веб-приложение. Кодинг.

Продолжаем готовить виджет погоды. Google Weather API благополучно умер, поэтому для получения данных предлагаю использовать OpenWeatherMap API - бесплатно, без регистрации, раздает данные в формате JSON, в общем мне сервис понравился.


После ознакомления с документацией сервиса выполним запрос - в адресной строке браузера пишем: http://api.openweathermap.org/data/2.5/forecast/daily?q=Moscow&units=metric&cnt=1&lang=ru и переходим по указанному адресу.
Результат копируем в текстовый файл, приводим его в читабельный вид и сохраняем - пригодится для понимания структуры ответа сервера.
{
 "cod":"200",
 "message":0.022,
 "city":{
  "id":524901,
  "name":"Moscow",
  "coord":{
   "lon":37.615555,
   "lat":55.75222
  },
  "country":"RU",
  "population":1000000
 },
 "cnt":1,
 "list":[
  {
   "dt":1373187600,
   "temp":{
    "day":29.29,
    "min":20.97,
    "max":29.29,
    "night":22.35,
    "eve":27.73,
    "morn":20.97
   },
   "pressure":1011.68,
   "humidity":58,
   "weather":[
    {
     "id":501,
     "main":"Rain",
     "description":"дождь",
     "icon":"10d"
    }
   ],
   "speed":2.76,
   "deg":258,
   "clouds":32,
   "rain":4.5
  }
 ]
}

Принципиальная схема нашего веб-приложения будет выглядеть следующим образом:

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

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

Теперь попробую обрисовать схему работы приложения вербально:
- для начала выполняем инициализацию - читаем шаблоны (template.html, form.html), список городов (cities.txt), сохраняем данные в хранилище Redis
- выполняем запрос к сервису OpenWeatherMap API, сохраняем ответ сервиса в хранилище Redis
- запускаем веб-сервер на основе фреймворка Express, в ответ на входящие запросы получаем шаблоны из хранилища Redis, подставляем в них необходимые данные, полученные из того же хранилища, после чего отправляем собранную таким образом HTML-страницу клиенту

Довольно лирики, let's get our hands dirty :).

Пишем код шаблона HTML-страницы, отображающей данные прогноза погоды.
<!DOCTYPE html>
<html>
<head><link rel="stylesheet" type="text/css" href="public/style{{align}}.css" /></head>
<body>
  <div> {{city}} </div>
  {{#days}}
  <ul>
 <li>{{dt}}</li>
 <li>{{description}}</li>
    <li>Утро: {{morn}} °C</li>
 <li>День: {{day}} °C</li>
    <li>Вечер: {{eve}} °C</li>
 <li>Ночь: {{night}} °C</li>
 <li>Давление: {{pressure}} мм</li>
 <li>Влажность: {{humidity}} %</li>
 <li>Облачность: {{clouds}} %</li>
 <li>Ветер: {{speed}} м/с</li>
  </ul>
  {{/days}}
</body>
</html>

Сохраняем файл с именем template.html в созданный в предыдущей статье каталог ~/_node/weather.

Обращаем внимание на двойные скобки в коде шаблона, после чего читаем документацию модуля mustache.
Становится понятно для чего нам нужен этот модуль - с его помощью удобно рендерить шаблоны (не нашел подходящее русское слово).

Похожим образом пишем код шаблона HTML-страницы настройки виджета.
Реализуем возможность выбора города, периода прогноза (от 1 до 7 дней), а также вертикального или горизонтального расположения.
<!DOCTYPE html>
<html>
<head>
 <script type="text/javascript" src="public/getcode.js"></script> 
</head>
<body>
 <form id="data" action="http://localhost:3000/get">
  <div>Расположение</div>
  <input type="radio" name="align" value="v" checked="checked" />Вертикальное<br/>
  <input type="radio" name="align" value="h" />Горизонтальное<br/>  
  <div>Город</div>
  <select form="data" name="city" id="city">
   {{#cities}}
   <option name="city" value="{{city}}">{{city}}</option>
   {{/cities}}
  </select><br/>  
  <div>Период (дней)</div>
  <select form="data" name="days" id="days">
   <option name="days" value="1">1</option>
   <option name="days" value="2">2</option>
   <option name="days" value="3">3</option>
   <option name="days" value="4">4</option>
   <option name="days" value="5">5</option>
   <option name="days" value="6">6</option>
   <option name="days" value="7">7</option>
  </select><br/>  
 </form>
 <input type="submit" value="Получить код" onclick="getCode()" /> 
 <input type="submit" form="data" value="Показать" /><br/>
 <textarea id="code" rows="10"></textarea>
</body>
</html>

Сохраняем файл с именем form.html.

Очевидно не хватает списка городов. Создаем файл cities.txt следующего содержания:
Saint Petersburg,  Moscow,   Nizhniy Novgorod

Полный список городов можно узнать из документации API.

Пора прочитать содержимое наших шаблонов и сохранить данные в хранилище Redis.
Но перед этим создадим модуль настроек нашего приложения, который будет экспортировать конструктор объекта. Назовем его settings.js.
module.exports = function (){
 this.citiesPath = 'cities.txt',
 this.dbCitiesKey = 'cities',
 this.templatePath = 'template.html',
 this.dbTemplateKey = 'template',
 this.formPath = 'form.html',
 this.dbFormKey = 'form',
 this.dbDateKey = 'dt',
 this.dbIndex = 15,
 this.uriTemplate = 'http://api.openweathermap.org/data/2.5/forecast/daily?q={{city}}&units=metric&cnt=7&lang=ru',
 this.interval = 1000 * 60 * 30, // 30 минут
 this.port = 3000
}

Переходим к созданию модуля инициализации по имени init.js.
var Settings = require('./settings');
var settings = new Settings();

var fs = require('fs');
var redis = require("redis");

init(settings.citiesPath, settings.dbCitiesKey, settings.dbIndex);
init(settings.templatePath, settings.dbTemplateKey, settings.dbIndex);
init(settings.formPath, settings.dbFormKey, settings.dbIndex);

function init(path, key, dbIndex) {
 fs.exists(path, function(exists) {
  if (!exists) {
   console.log('Path ' + path + ' does not exists');
  } else {
   fs.readFile(path, function(err, content) {
    if (err) {
     console.log('Cannot read file ' + path);
    } else {
     var client = redis.createClient();
     client.on("error", function (err) {
      console.log("Error " + err);
     });
     client.select(dbIndex, function() {     
      var value = (key == 'cities') ? JSON.stringify(String(content).replace(/^\s+/, "").split(/\s*,\s*/)) : String(content).replace(/^\s+/, "");     
      client.set(key, value, redis.print);     
      client.quit();
     });
    }
   });
  }
 });
}

В коде модуля обращаю внимание на то, что для работы с Redis мы используем модуль node_redis.

Выполняем первоначальную настройку нашего приложения:
- запускаем redis-server
- проверим доступность сервера
- выберем хранилище с индексом 15 (согласно настройкам - файл settings.js)
- убедимся в том, что хранилище пустое
- переходим в каталог нашего приложения
- выполняем инициализицию, в случае успеха получаем OK на каждый сохраненный шаблон
- читаем ключи хранилища еще раз

- читаем значения ключей

Теперь в случае изменения какого-либо шаблона для обновления данных в хранилище достаточно выполнить в терминале (консоли) nodejs init (в Windows node init).

Кстати на счет Windows. Как вы уже наверняка заметили в настоящей статье на скриншотах исключительно Ubuntu.
На самом деле разработка нашего приложения в Windows практически ничем, за исключением нескольких мелочей, о которых я постараюсь сообщать читателю в процессе текущего повествования, не отличается от разработки в Linux.

Об одном отличие я уже сообщил: для выполнения кода в консоли Node.js Windows выполняем команду с префиксом node, в терминале Linux - с префиксом nodejs.

Для тех, кто не читал предыдущую статью, еще одно отличие: в Windows redis-server и redis-cli удобнее запускать в отдельной консоли путем выполнения одноименного exe-файла.



Подведем промежуточный итог. На текущем этапе содержание каталога приложения выглядит примерно так:

Вернемся к коду шаблона form.html.
В заголовке присутствует ссылка на скрипт, который выполняется по событию нажатия на кнопку "Получить код" и собирает код виджета для размещения на веб-сайте исходя из выбранных параметров, после чего отображает полученный код в текстовом поле.
Пишем код клиентского скрипта.
function getCode() {   
 var days = document.getElementById('days');
 var city = document.getElementById('city')
 var req = ['days=' + days[days.selectedIndex].value, 'city=' + city[city.selectedIndex].value];    
 var rads = document.getElementsByName('align');
 for (i=0; i < rads.length; i++) {
  if (rads[i]['checked']) {
   req.push('align=' + rads[i].value);
   break;
  }
 }  
 document.getElementById("code").value = '<iframe src="http://localhost:3000/get?' + 
  req.join('&') + '" width="100%" height="100%" frameborder="0"></iframe>';
} 

В каталоге приложения создаем каталог по имени public, сохраняем скрипт в созданный каталог, назовем его getcode.js.
Открываем файл в браузере и нажимаем на кнопку "Получить код".

Клиентский скрипт отработал как надо.

И еще на несколько секунд обратим внимание на код шаблона template.html, в заголовке которого есть ссылка на css-файл:
<link rel="stylesheet" type="text/css" href="public/style{{align}}.css" />

В зависимости от того, горизонтальное или вертикальное расположение виджета выбрал пользователь, здесь может быть ссылка на файл styleh.css или stylev.css соответственно.
Создадим эти файлы в каталоге public.

styleh.css:
body{text-align: center;}
div{font: bold 16px verdana, sans-serif; color: #f00;}
ul{text-align: left; display: inline-block; margin: 0; padding: 0; list-style-type: none; margin: 10px;}
ul li:first-child{font-weight: bold; color: #00008b;}
ul li:nth-child(2){font-weight: bold; color: #008080;}

stylev.css:
body{text-align: left;}
div{font: bold 16px verdana, sans-serif; color: #f00;}
ul{text-align: left; margin: 0; padding: 0; list-style-type: none; margin-top: 10px;}
ul li:first-child{font-weight: bold; color: #00008b;}
ul li:nth-child(2){font-weight: bold; color: #008080;}

Возвращаемся в корневой каталог нашего приложения.
Пишем модуль обновления данных прогноза, который будет выполнять запрос и сохранять полученные данные в хранилище Redis. Назовем его request.js. Для выполнения запроса используем модуль node-request.
var Settings = require('./settings');
var settings = new Settings();

var request = require('request');
var redis = require("redis");
var mus = require('mustache');

exports.get = function() {
 var client = redis.createClient();
 client.on('error', function (err) {
  console.log('Error ' + err);
 });
 client.select(settings.dbIndex, function() {
  client.get('cities', function(err, reply) {
   if (err) {
    console.log('Error ' + err);
   } else {
    var cities = JSON.parse(reply);
    for (var i=0; i<cities.length; i++) {   
     var url = {'url': mus.render(settings.uriTemplate, {'city': cities[i]})};
     request(url, function (err, response, body) {
      if (!err && response.statusCode == 200) {
       var forecast = JSON.parse(body);
       var client = redis.createClient();
       client.on('error', function (err) {
        console.log('Error ' + err);
       });
       client.select(settings.dbIndex, function() {      
        client.set('forecast:' + forecast.city.name, JSON.stringify(forecast), redis.print); // прогноз
        client.set(settings.dbDateKey, new Date().toString()); // дата обновления
        client.quit();
       });
      } else {
       console.log('Error ' + err);
      }
     });
    }
   }
  });
  client.quit();
 });
}

Закомментируем для теста единственную функцию, которую экспортирует созданный модуль.

Выполним код модуля.

По одному ОК на каждый город из списка. Не забываем раскомментировать обратно экспортируемую функцию.

В завершение пишем код главного файла приложения. Назовем его app.js.
var Settings = require('./settings');
var settings = new Settings();

var request = require('./request');
request.get();

var redis = require("redis");
var mus = require('mustache');

var express = require('express');
var app = express();
app.use("/public", express.static(__dirname + '/public'));

app.get('/get', function(req, res){ 
 var query = {
  'city': req.query.city || 'Moscow', 
  'days': req.query.days || 7, 
  'align': (!req.query.align) ? 'v' : req.query.align // по умолчанию - вертикальная
 };
 
 var client = redis.createClient();
 client.on('error', function (err) {
  console.log('Error ' + err);
 });
 client.select(settings.dbIndex, function() {
  client.get('forecast:' + query.city, function(err, reply) {
   if (err) {
    console.log('Error ' + err);
   } else {
    var forecast = JSON.parse(reply);      
    var view = {'align': query.align, 'city': query.city, 'days':[]};
    var toMm = 0.75006375541921; // гектопаскали в мм
    for (var i=0; i<query.days; i++) {
     var obj = {
      'dt': new Date(forecast.list[i].dt * 1000).toLocaleDateString(),
      'description': forecast.list[i].weather[0].description, 
      'morn': Math.round(forecast.list[i].temp.morn), 
      'day': Math.round(forecast.list[i].temp.day), 
      'eve': Math.round(forecast.list[i].temp.eve), 
      'night': Math.round(forecast.list[i].temp.night), 
      'pressure': Math.round(forecast.list[i].pressure * toMm),
      'humidity': Math.round(forecast.list[i].humidity), 
      'clouds': Math.round(forecast.list[i].clouds), 
      'speed': Math.round(forecast.list[i].speed), 
     };
     view.days.push(obj);
    }   
    client.get(settings.dbTemplateKey, function(err, template) {
     if (err) {
      console.log('Error ' + err);
     } else {
      var output = mus.render(template, view);
      res.send(output);
     }
    });    
   }
   client.quit();
  });
 });
});

app.get('/', function(req, res){ 
 var client = redis.createClient();
 client.on('error', function (err) {
  console.log('Error ' + err);
 });
 client.select(settings.dbIndex, function() {
  client.get(settings.dbCitiesKey, function(err, reply) {
   if (err) {
    console.log('Error ' + err);
   } else {
    var cities = JSON.parse(reply);
    var view = {'cities':[]};
    for (var i=0; i<cities.length; i++) {
     view.cities.push({'city': cities[i]});
    }
    client.get(settings.dbFormKey, function(err, template) {
     if (err) {
      console.log('Error ' + err);
     } else {
      var output = mus.render(template, view);
      res.send(output);
     }
    });    
   }
   client.quit();
  });  
 });
});

app.listen(settings.port);
console.log('Listening on port ' + settings.port);

(function schedule() {
 setTimeout(function update() {  
  var client = redis.createClient();
  client.on('error', function (err) {
   console.log('Error ' + err);
  });
  client.select(settings.dbIndex, function() {
   client.get(settings.dbDateKey, function(err, reply) {
    if (err) {
     console.log('Error ' + err);
    } else {
     if (new Date().toLocaleDateString() != new Date(reply).toLocaleDateString()) {
      request.get();
     }
    }
   });
   client.quit();
  });
  schedule();  
 }, settings.interval);
}());

Здесь главную роль играет фреймворк Express, с помощью которого создается http-сервер, ожидающий подключения на порту 3000.
В самом низу в коде располагается функция, которая следит за изменением текущей даты (по умолчанию каждые 30 минут), и в случае наступления события обновляет данные - выполняет запрос и сохраняет данные прогноза в хранилище.

Запускаем сервер.

Открываем в браузере адрес http://localhost:3000 . Тестируем полученное приложение.
Получаем код виджета.

Отображаем виджет.

Интерфейс мягко говоря аскетичный, "шустрость" проверяем сами.

За сим прощаюсь. Покупайте наших слонов :).