Страницы

понедельник, 18 февраля 2013 г.

Google Apps Script. UiApp, HtmlService, ContentService.


Какой стиральный порошок самый лучший? Холивар :).
В этой статье я попробую решить одну и ту же задачу тремя разными способами: с помощью сервисов UiApp, HtmlService и ContentService.  Цель: рассмотреть на примере практику применения этих служб, отметить плюсы и минусы, сделать "заметки на полях". Задача: предоставить доступ к данным всем заинтересованным пользователям.

Определимся с источником данных.
Как-то раз я подписался на рассылку сайта PowerShell.com. Не помню как это случилось, может зарегился в процессе серфинга. Суть в том, что с тех пор мне на один почтовый ящик почти каждый день приходят интересные типсы по PS (должен признать, что я их почти никогда не читаю). Их то я и планирую использовать в качестве источника.

Идем на drive.google.com, создаем скрипт. Я назвал его GetGmailUiApp.
В созданный по-умолчанию файл Код.gs пишем код функции doGet().
function doGet() { 
  var app = UiApp.createApplication().setTitle('PowerShell Tips');
  // информационная панель
  var infoGrid = app.createGrid(3, 1).setId('infoGrid')   
    .setStyleAttributes({top:'20', left:'350', border:'solid 1px blue', position:'absolute', opacity: '0.85'})
    .setStyleAttribute('border-radius','10px 10px 10px 10px')
    .setStyleAttribute('background-color', 'lightblue')
    .setVisible(false);
  // обработчик события нажатия на кнопку - скрыть информационную панель
  var onOkClick = app.createClientHandler().forTargets(infoGrid).setVisible(false);
  // кнопка на информационной панели
  var btnOk = app.createButton('OK').setId('btnOK').setWidth(100)
    .setStyleAttributes({border:'solid 1px blue'})
    .setStyleAttribute('border-radius','10px 10px 10px 10px')
    .setStyleAttribute('background-color', 'lightblue')
    .setStyleAttribute('font-weight', 'bold')
    .addClickHandler(onOkClick);
  var lblHeader = app.createLabel().setId('lblHeader').setStyleAttribute('font-weight', 'bold');
  var lblContent = app.createHTML().setId('lblContent').setSize(700, 525); 

  // добавляем элементы на информационную панель
  infoGrid.setWidget(0, 0, lblHeader).setWidget(1, 0, app.createScrollPanel(lblContent)).setWidget(2, 0, btnOk)
    .setStyleAttribute(0, 0, 'text-align', 'center').setStyleAttribute(2, 0, 'text-align', 'center');

  app.add(infoGrid);

  // почта
  var threads = GmailApp.search('from:Powershell.com',0,20); 
  
  // обрабатываем события на клиенте
  var onMouseOver = app.createClientHandler().forEventSource().setStyleAttribute('background-color', 'lightblue');
  var onMouseOut = app.createClientHandler().forEventSource().setStyleAttribute('background-color', 'white');
  // обрабатываем событие на сервере
  var onLabelClick = app.createServerHandler('onLabelClick_');

  for (var i=0; i&ltthreads.length; i++) {   
    var lblDate = app.createLabel('Дата: ' + Utilities.formatDate(threads[i].getLastMessageDate(),Session.getTimeZone(),"yyyy-MM-dd' 'HH:mm:ss"))
      .setWidth(200).addMouseOverHandler(onMouseOver).addMouseOutHandler(onMouseOut);
    var lblSubject = app.createLabel('Тема: ' + threads[i].getFirstMessageSubject())
      .setWidth(600).addMouseOverHandler(onMouseOver).addMouseOutHandler(onMouseOut); 
  
    var grid = app.createGrid(1, 2)
      .setStyleAttributes({margin: '5px', padding: '1px', border: '1px dotted lightblue', cursor: 'pointer'})
      .setWidget(0, 0, lblDate).setWidget(0, 1, lblSubject)
      .setId(threads[i].getId()).addClickHandler(onLabelClick);
  
    app.add(grid);   
  }   
  return app;   
}
Обработать события MouseOver и MouseOut на клиенте я нашел возможность только для виджета Label (не нашел для Grid), поэтому повесил хэндлеры на них. Для виджета Grid пришлось повесить обработку на сервер, что, на мой взгляд, не особенно повлияло на скорость веб-приложения. Добавляем код функции обратотчика события.
function onLabelClick_(e) {
  var app = UiApp.getActiveApplication();
  try { 
    var message = GmailApp.getMessageById(e.parameter.source);   
    app.getElementById('lblHeader').setText(message.getSubject());
    app.getElementById('lblContent').setHTML(message.getBody());
    //app.getElementById('lblContent').setText(message.getBody());   
    app.getElementById('infoGrid').setVisible(true);
  } 
  catch(e) {
    Logger.log(e.message)
  } 
  return app; 
}
Сохраняем, авторизуем, публикуем, тестируем.


Из положительных моментов могу отметить только скорость выполнения. Можно удалить аргументы метода search сервиса GmailApp (под комментарием "// почта" заменить строку на "var threads = GmailApp.search('from:Powershell.com');") и попытаться отобразить все сообщения от PowerShell.com (в настоящий момент их больше сотни).
Не смотря на то, что для извлечения заголовка и содержания сообщения после события нажатия на виджет Grid приложение каждый раз обращается к функции onLabelClick_() на сервер, он довольно бодро возвращает нам требуемые данные. Да и в процессе формирования даты сообщения (var lblDate) в цикле for код двадцать раз подряд обращается к сервису Utilities, но это никак не отражается на производительности.

Теперь о неприятном. Сообщения с сайта PowerShell.com приходят в формате HTML. Похоже код HTML не валидно проходит санитарную обработку на сервере (издержки мер безопасности), так как содержит внешние ссылки и прочие официально "не разрешенные" гуглами тэги, после чего метод setHTML (в функции onLabelClick_()) ничего не отображает в информационной панели. Если попробовать использовать вместо него метод setText (раскомментируйте строку //app.getElementById('lblContent').setText(message.getBody()); и закомментируйте строку выше), то мы благополучно получим HTML-код сообщения в информационной панели.

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

Если сервис UiApp мне напомнил SilverLight от Microsoft, то после знакомства с HtmlService я ассоциирую его с чем-то типа PHP или Razor (прошу строго не судить - мое скромное понимание). Но обо всем по-порядку.

Создадим новый скрипт, назовем его GetGmailHtmlService. В единственной строке функции doGet() сразу же возвращаем значение с помощью сервиса HtmlService.
function doGet() { 
  return HtmlService.createTemplateFromFile('template').evaluate().setTitle('PowerShell Tips');
}
В этой самой строке нетрудно заметить аргумент 'template' - шаблон HTML-документа, который нам с вами и предстоит создать:  Файл - Создать - HTML-документ, и назовем его... правильно, template.html.
Созданный документ не содержит ничего, кроме тэгов <html>, поэтому предлагаю сразу же его наполнить содержанием, после чего разберемся что к чему.
<html> 
  <style type="text/css">
    .c0 {margin: 5px; padding: 3px; border: 1px dotted lightblue; cursor: pointer; font: 14px sans-serif; height: 18px}
    .c1 {width: 300px; float: left}
    .c2 {float: left}
    #info {position: absolute; left: 350px; top: 20px; opacity: 0.85; border-radius: 10px 10px 10px 10px;
      border: solid 1px blue; background-color: lightblue; width: 700px; visibility: hidden}   
  </style>

  <script language="JavaScript">
    <? var messages = getMessages(); ?>;
    // магия здесь - вставляем наш объект messages с сервера
    var messages = <?!= JSON.stringify(messages) ?>;   
  
    function onClick(id) {
      var info = document.getElementById('info');
      info.innerHTML = messages[id.substring(1)].body;
      info.style.visibility = 'visible';
    }   
  </script>
  <body>
    <div id="info" onclick="this.style.visibility='hidden'"></div>
    <? for (var i=0; i<messages.length; i++) { ?>
    <div class="c0" id="m<?= i ?>" onmouseover=
    "this.style.backgroundColor='lightblue'" onmouseout="this.style.backgroundColor='white'" onclick="onClick(this.id)">
      <div class="c1">Дата: <?= messages[i].date ?></div>
      <div class="c2">Тема: <?= messages[i].subject ?></div>     
    </div>
    <? } ?>   
  </body> 
</html>
По-моему все понятно без разъяснений: на кленте старая добрая связка HTML-CSS-JavaScript, серверный код предваряют символы "<?" и завершают символы "?>".
Остается добавить на сервер функцию извлечения сообщений (в файл Код.gs).
function getMessages() {
  var threads = GmailApp.search('from:Powershell.com',0,20);
  var messages = [];
  for (var i=0; i<threads.length; i++) {
    var message = {
      date: threads[i].getLastMessageDate().toLocaleString(),
      subject: threads[i].getFirstMessageSubject(),
      id: threads[i].getId(),
      body: GmailApp.getMessageById(threads[i].getId()).getBody()
    };
    messages.push(message);
  } 
  return messages;
}
Алгоритм не изменился: сохраняем, авторизуем, публикуем, тестируем.


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

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

Желающие подробнее ознакомиться с состоянием отрасли, или просто попрактиковаться в indian english (сорри, не смог удержаться :)), смотрим доклад: State of the Script 2013.

Хотя, что касается скорости - похоже здесь есть и моя вина. У меня возникли сомнения на счет кода шаблона: может достаточно было просто один раз вытащить данные о сообщениях с сервера, а при формировании HTML ограничиться клиентским JavaScript?
Еще раз по-русски: предлагаю цикл for в файле template.hmtl, который в процессе формирования тела HTML-страницы то и дело обращается к переменной messages на сервере заменить на код на клиенте, чтобы таким образом свести коммуникацию с серверным кодом к первым двум строчкам клиентского скрипта: "<? var messages = getMessages(); ?>;" и "var messages = <?!= JSON.stringify(messages) ?>;". Может это повлияет на скорость?

Создадим еще один файл шаблона, назовем его template2.html.
<html> 
  <style type="text/css">
    .c0 {margin: 5px; padding: 3px; border: 1px dotted lightblue; cursor: pointer; font: 14px sans-serif; height: 18px}
    .c1 {width: 300px; float: left}
    .c2 {float: left}
    #info {position: absolute; left: 350px; top: 20px; opacity: 0.85; border-radius: 10px 10px 10px 10px;
      border: solid 1px blue; background-color: lightblue; width: 700px; visibility: hidden}   
  </style>

  <script language="JavaScript">
    <? var messages = getMessages(); ?>;
    // магия здесь - вставляем наш объект messages с сервера
    var messages = <?!= JSON.stringify(messages) ?>;
  
    window.onload = function() {     
      for (var i=0; i<messages.length; i++) {
      
        var div = document.createElement('div');
        div.className = 'c0';
        div.id = 'm' + i;
        div.onmouseover = function() {this.style.backgroundColor='lightblue'};
        div.onmouseout = function() {this.style.backgroundColor='white'};
        div.onclick = function() {onClick(this.id)};       
        document.body.appendChild(div);
      
        var div1 = document.createElement('div');
        div1.className = 'c1';
        div1.innerText = 'Дата: ' + messages[i].date;
        div.appendChild(div1);
      
        var div2 = document.createElement('div');
        div2.className = 'c2';
        div2.innerText = 'Тема:  ' + messages[i].subject;
        div.appendChild(div2);       
      }     
    }
  
    function onClick(id) {
      var info = document.getElementById('info');
      info.innerHTML = messages[id.substring(1)].body;
      info.style.visibility = 'visible';
    }   
  </script>
  <body>
    <div id="info" onclick="this.style.visibility='hidden'"></div>     
  </body> 
</html>
Уверен, что вы могли бы написать более лаконичный код, у меня как-то по-стариковски получилось :). Обещаю, что далее по тексту постараюсь привести клиентский скрипт в более приличное состояние.

Вернемся в файл Код.gs, в функции doGet меняем 'template' на 'template2'.
До кучи подмолодим код функции getMessages(). В теле цикла for код двадцать раз подряд обращается к GmailApp для извлечения содержания сообщения, что также может отразиться на производительности, т.к. у сервиса существуют ограничения на количество обращений в секунду. Эффективнее будет предварительно извлечь массив сообщений с помощью метода getMessagesForThreads.
function getMessages() {
  var threads = GmailApp.search('from:PowerShell.com',0,20);
  var messages = GmailApp.getMessagesForThreads(threads);
  var arr = [];
  for (var i=0; i<threads.length; i++) {
    var message = {
      date: threads[i].getLastMessageDate().toLocaleString(),
      subject: threads[i].getFirstMessageSubject(),
      id: threads[i].getId(),
      body: messages[i][0].getBody()
    };
    arr.push(message);
  } 
  return arr;
}
Сохраняем новую версию, обновляем, тестируем.

Уже лучше, но еще есть куда расти :).

Хотелось бы чуть более подробно остановиться на возможности обмена данными между клиентской и серверной частями приложения.
Обратите внимание на комментарий в самом начале клиентского скрипта шаблона - "// магия здесь...". Магия действительно рядом - строчкой выше и строчкой ниже, но можно обойтись и без нее.
В настоящий момент клиентская часть получает сообщения (var messages) от серверной части путем преобразования в строку объекта JSON, полученного после выполнения на сервере функции getMessages().
? var messages = getMessages(); ?>;
  // магия здесь - вставляем наш объект messages с сервера
  var messages = <?!= JSON.stringify(messages) ?>;
На самом деле есть более вкусный способ без танцев с бубном.
Создадим еще один файл шаблона, назовем его template3.html. Копируем в него код шаблона template2.html и редактируем его следующим образом.
<html> 
  <style type="text/css">
    .c0 {margin: 5px; padding: 3px; border: 1px dotted lightblue; cursor: pointer; font: 14px sans-serif; height: 18px}
    .c1 {width: 300px; float: left}
    .c2 {float: left}
    #info {position: absolute; left: 350px; top: 20px; opacity: 0.85; border-radius: 10px 10px 10px 10px;
      border: solid 1px blue; background-color: lightblue; width: 700px; visibility: hidden}   
    #w1 {left: 0px; top: 50%; width: 100%; text-align: center; position: absolute} 
 #w2 {left: 50%; top: -160px; height: 320px; width: 480; margin-left: -240px; position: absolute}
  
  </style>

  <script language="JavaScript">   
    window.onload = function() {
      google.script.run.withSuccessHandler(gotMessages).getMessages();     
      var img = document.createElement('img');
      img.id = 'wait';
      img.src= 'https://lh5.googleusercontent.com/-f5N3UKXcakQ/USB6zpyOZvI/AAAAAAAAAYw/vJT5u02P4LA/s480/wait1.gif';
      document.getElementById('w2').appendChild(img);     
    }
  
    function gotMessages(messages) {
      document.getElementById('wait').style.visibility = 'hidden';     
      for (var i=0; i<messages.length; i++) {
        var div = document.createElement('div');
        div.className = 'c0';
        div.id = 'm' + i;
        div.onmouseover = function() {this.style.backgroundColor='lightblue'};
        div.onmouseout = function() {this.style.backgroundColor='white'};
        div.onclick = function() {
          var info = document.getElementById('info');
          info.innerHTML = messages[this.id.substring(1)].body;
          info.style.visibility = 'visible';
        };       
        document.body.appendChild(div);
      
        var div1 = document.createElement('div');
        div1.className = 'c1';
        div1.innerText = 'Дата: ' + messages[i].date;
        div.appendChild(div1);
      
        var div2 = document.createElement('div');
        div2.className = 'c2';
        div2.innerText = 'Тема:  ' + messages[i].subject;
        div.appendChild(div2);       
      }     
    }   
  </script>
  <body>
    <div id="info" onclick="this.style.visibility='hidden'"></div>
    <div id="w1"><div id="w2"></div></div>
  </body> 
</html>
Сразу после загрузки клиентский скрипт вызывает функцию getMessages() на сервере, где код продолжает выполняться асинхронно.
google.script.run.withSuccessHandler(gotMessages).getMessages();
Для того, чтобы пользователь не сильно заскучал, ожидая ответ серверной части, покажем ему недвусмысленную картинку :).
var img = document.createElement('img');
img.id = 'wait';
img.src= 'https://lh5.googleusercontent.com/-f5N3UKXcakQ/USB6zpyOZvI/AAAAAAAAAYw/vJT5u02P4LA/s480/wait1.gif';
document.getElementById('w2').appendChild(img);
В случае успешного завершения выполнения кода на сервере (withSuccessHandler) результат отправляется в функцию gotMessages(), которая скрывает картинку и отображает полученные данные.

Похожим образом вы можете предусмотреть работу клиентского кода на случай ошибки.
google.script.run.withFailureHandler(gotFailure).getMessages();
Это уже лирика. Если есть желание - попробуйте генерировать ошибку на сервере и получить информацию о ней на клиенте.
Еще один вариант оптимизации веб-приложения - использование сервиса CacheService, но по-моему в нашем случае это уже перебор.

Продолжаем разговор. Сохраняем новую версию, обновляем, тестируем.

Прогресс налицо. Не все по фэн-шую, но, позволю себе повториться, гуглы в курсе и работают над увеличением производительности сервисов.

В заключение переходим к наименее затратному способу решения задачи.
Создаем скрипт, назовем его GetGmailContentService. Пишем код функции doGet().
function doGet() {
  return ContentService.createTextOutput(HtmlService.createTemplateFromFile('template').evaluate().getContent())
    .setMimeType(ContentService.MimeType.RSS);
}
В двух словах происходит следующее: HtmlService создает rss-содержимое согласно шаблону по имени template, а ContentService в свою очередь раздает это самое содержимое в формате rss.
В качестве домашнего задания предлагаю поинтересоваться какими еще типами данных кроме rss оперирует ContentService.

Создадим файл RSS-шаблона.
<rss version="2.0">
  <channel>
    <title>PowerShell Tips</title>
    <? var threads = GmailApp.search('from:PowerShell.com');
       var messages = GmailApp.getMessagesForThreads(threads);
       for (var i=0; i<threads.length; i++) { ?>
       <item>
         <title><?= threads[i].getFirstMessageSubject(); ?></title>
         <description><?= messages[i][0].getBody(); ?></description>
         <guid><?= threads[i].getId(); ?></guid>
         <pubdate><?= threads[i].getLastMessageDate().toLocaleString(); ?></pubdate>
      </item>
    <? } ?>
  </channel>
</rss>
Обращаю внимание, что в коде шаблона мы не стесняемся получить все сообщения, а не первые двадцать (как в случае с HtmlService).
Сохраняем, авторизуем, публикуем. Не забываем установить доступ "Все, включая анонимных пользователей".
Заходим на Google Reader, нажимаем на большую красную кнопку "Подписаться".


Добавляем фид... Вуаля.


Считанные строчки кода и фид готов. Понятно, что можно использовать любой rss-агрегатор. Остается только научить пользователя его пользовать :).
Желающие - подписываемся.

Подведем итог. Так каким же порошком стирать? Рецепт, как говорится, in the air:
- на случай так называемой "rapid application development", если нет необходимости иметь дело с HTML, я бы ограничился UiApp;
- для создания более "rich internet application" по понятным причинам больше подойдет HtmlService;
- в случае разработки службы (RSS, XML, JSON и пр.) выбор очевиден - ContentService;

За сим прощаюсь. Peace out everyone :)