Разработка приложения живого поиска по Twitter с помощью Knockout, jQuery и ASP.NET MVC 3

Автор оригинала: Anders Fjeldstad
  • Перевод
Достаточно не тривиально разработать хорошо спроектированный front-end веб-приложения с уровнем отклика, производительностью и фичами, которые ожидают пользователи сегодня. Легко потеряться в кипящей смеси jQuery обработчиков событий, HTML разметки и AJAX вызовов и даже относительно простой GUI быстро может стать кошмаром для сопровождения.

Один из способов добавления некоторой структуры и порядка на клиентской стороне – использование фреймворка вроде Knockout. Knockout – это свободная Javascript библиотека с открытым исходным кодом, которая помогает реализовать Model-View-View Model (MVVM) паттерн на клиенте. Она хорошо документирована и официальный веб сайт великолепная точка старта с кучей реальных примеров, которые не только демонстрируют использование встроенных фич библиотеки, но и показывают, как расширить её свои собственными фичами.

В этом посте мы рассмотрим, как Knockout может быть использован совместно с плагином отображения, каким-нибудь jQuery и ASP.NET MVC 3 backend (или модель, как вам нравится) для построения простого, но эффективного приложения для живого поиска в Twitter.


Цель


Наша цель проста: пользователю следует иметь возможность ввода некоторых поисковых критериев (вроде hashtag или имени пользователя Twitter), после этого приложению следует представить соответствующие самые актуальные твиты. Также, множество результатов следует автоматически обновлять в некой фоновой процедуре на клиенте так, чтобы GUI всегда показывал самые последние найденные твиты. (Это может быть полезным, например, при отслеживании некоторого события, которое происходит здесь и сейчас.) В конечном итоге, мы хотели бы получить что-то похожее на это:


Шаг 1: Определение модели


Twitter представляет поисковый API, который достаточно прост для использования и который может возвращать ответ на поисковые запросы в XML (Atom) или JSON формате. Мы могли бы позволить нашему клиенту напрямую общаться с Twitter API, но это лишило бы нас возможности улучшить приложение через добавление кеширования, какой-нибудь заглушки на случай падения Twitter и т.д.; поэтому хорошей идей было бы создать вместо этого нашу собственную модель, и для этого мы собираемся использовать очень простое ASP.NET MVC 3 приложение.

Во-первых, создайте стандартное пустое веб-приложение (с Razor в качестве движка отображения) и добавьте контроллер – давайте назовём его TwitterController. Затем переименуйте “Index” действие, которое автоматически создалось вместе с новым контроллером, в “Search”. Контроллер будет выглядеть как-то так:
public class TwitterController : Controller
{
    [HttpGet]
    public ActionResult Search()
    {
        return View();
    }
}


* This source code was highlighted with Source Code Highlighter.

Далее, мы собираемся создать метод действия, который выполняет актуальный поиск в Twitter. Для этой задачи, мы будем использовать Atom версию поискового API. URL запроса соответствует следующему формату search.twitter.com/search.atom?q=[URL-encoded search parameters]. Т.к. Atom – это XML, мы можем использовать LINQ to XML для того, чтобы распарсить и преобразовать множество результатов (Twitter возвращает массу данных, которые нам будут не нужны, так что мы их сократим до минимума).

Но у нас нет желания посылать XML на клиент – лучше JSON. К счастью, легко конвертировать большинство объектов в JSON, и в данном случае простейший путь – это использовать встроенный Json метод, который возвращает JsonResult. Всё сводится к:
[HttpPost]
public ActionResult Search(string query)
{
    var atomResult = XDocument.Load(string.Format(
        "http://search.twitter.com/search.atom?q={0}",
        HttpUtility.UrlEncode(query)));
    XNamespace ns = "http://www.w3.org/2005/Atom";
    var searchResult =
        from tweet in atomResult.Descendants(ns + "entry")
        let image = tweet.Elements(ns + "link")
                         .Where(e => e.Attributes()
                                     .Any(a => a.Name == "rel" &&
                                                a.Value == "image"))
                         .First().Attribute("href").Value
        let url =  tweet.Elements(ns + "link")
                         .Where(e => e.Attributes()
                                     .Any(a => a.Name == "rel" &&
                                                a.Value == "alternate"))
                         .First().Attribute("href").Value
        select new
        {
            id = tweet.Element(ns + "id").Value,
            author = tweet.Element(ns + "author").Element(ns + "name").Value,
            imageUrl = image,
            tweetUrl = url,
            tweetText = tweet.Element(ns + "title").Value
        };
    return Json(searchResult);
}


* This source code was highlighted with Source Code Highlighter.

(Код выше включает процедуру парсинга для выделения аватара в профиле автора и ссылки на каждый отдельный твит.)

На этом всё – мы построили нашу модель!

Шаг 2: Реализация модели вида


До того, как мы начнём разрабатывать GUI, нам будет нужно добавить некоторые ссылки в наше решение. Либо через NuGet, либо вручную загрузив jQuery, Knockout и jQuery.tmpl. Затем добавьте “Search.cshtml” вид в Views/Twitter (не используйте “master page” или layout – легче получить представление о сути в небольшой задаче, похожую на нашу, если мы не будем размазывать решение по множеству отдельных файлов). Добавьте упомянутые Javascript ссылки. Когда вы это сделаете, у вас будет что-то похожее на это:
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <title>Twitter Live Search</title>
        <script src="@Url.Content("~/Scripts/jquery-1.6.2.js")"
                type="text/javascript"></script>
        <script src="@Url.Content("~/Scripts/jquery.tmpl.min.js")"
                type="text/javascript"></script>
        <script src="@Url.Content("~/Scripts/knockout-1.2.1.js")"
                type="text/javascript"></script>
    </head>
    <body>
        <!-- TODO -->
    </body>
</html>


* This source code was highlighted with Source Code Highlighter.

Настало время построить нашу модель вида. Модель вида включает логику приложения на клиентской стороне или, если вам нравится, она определяет что приложение может делать и какие свойства имеет, без непосредственной привязки к любому элементу разметки или DOM элементам.

Для этой задачи модель вида чрезвычайно проста: содержит 2 свойства, используемые для хранения текущего поискового запроса и найденных твитов, и соответственно метод для выполнения поиска по модели. Это может выглядеть так:
<script type="text/javascript">
    function SearchViewModel() {
        this.query = ko.observable('');
        this.tweets = ko.observableArray();
    }

    SearchViewModel.prototype.search = function () {
        var q = this.query();
        if (q.length > 0) {
            $.ajax({
                type: 'POST',
                url: '/Twitter/Search',
                data: { query: q },
                dataType: 'json',
                success: function (data) {
                    // TODO
                }.bind(this)
            });
        } else {
            // TODO
        }
    };
</script>


* This source code was highlighted with Source Code Highlighter.

Трудно сказать, что здесь есть что-то значимое; свойства запроса и твитов изначально пусты, поисковый метод использует jQuery для того, чтобы отправить AJAX запрос к нашей модели на сервере и вернуть результаты в виде JSON объекта (массива твитов).

Как нам следует реализовать “успешный” обработчик в методе поиска? Мы можем очистить массив твитов и добавить новые результаты поиска. Но помните, что мы хотим постоянно получать “обновления” для наших поисковых результатов, в идеале мы хотели бы просто добавлять новые твиты (и удалять старые), не заменяя коллекцию целиком.

Это великолепная возможность попробовать Mapping плагин для Knockout. После того, как мы его загрузили и добавили ссылку на скрипт, мы можем реализовать успешный обработчик AJAX запроса:
success: function (data) {
    ko.mapping.updateFromJS(this.tweets, data);
}.bind(this)


* This source code was highlighted with Source Code Highlighter.

И затем задать “key definition” для коллекции твитов (который используется отображением для того, чтобы определить какие элементы следует добавить, обновить или удалить), мы просто немного перепишем наш конструктор:
function SearchViewModel() {
    this.query = ko.observable('');
    this.tweets = ko.mapping.fromJS(
        [],
        {
            key: function (tweet) { return ko.utils.unwrapObservable(tweet.id) }
        });
}


* This source code was highlighted with Source Code Highlighter.

Заметьте, что мы определяем ключ для коллекции твитов как значение ID, которое Twitter также удобно предоставляет. Если вам нужно больше информации о Mapping плагине, посетите официальный сайт Knockout.

Следующее: реализация фичи авто-обновления. Каждый раз, когда есть поисковый запрос, соответствующие твиты должны обновляться, скажем, каждые 3 секунды для отражения изменений в модели. Один из способов достичь этого – завести успешный calback для поискового AJAX запроса вызывающего рекурсивно, но задержкой, метод поиска. Полная модель вида в этом случае выглядит так:
<script type="text/javascript">
    function SearchViewModel() {
        this.query = ko.observable('');
        this.tweets = ko.mapping.fromJS(
            [],
            {
                key: function (tweet) { return ko.utils.unwrapObservable(tweet.id) }
            });
        this.autoRefreshId = null;
    }

    SearchViewModel.prototype.search = function () {
        var timeoutId = this.autoRefreshId;
        if (timeoutId) {
            window.clearTimeout(timeoutId);
            this.autoRefreshId = null;
        }
        var q = this.query();
        if (q.length > 0) {
            $.ajax({
                type: 'POST',
                url: '/Twitter/Search',
                data: { query: q },
                dataType: 'json',
                success: function (data) {
                    ko.mapping.updateFromJS(this.tweets, data);
                    this.autoRefreshId = window.setTimeout(
                        this.search.bind(this), 3000);
                }.bind(this)
            });
        } else {
            ko.mapping.updateFromJS(this.tweets, []);
        }
    };
</script>


* This source code was highlighted with Source Code Highlighter.

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

Шаг 3: Дизайн вида


Для того чтобы сохранить вещи простыми и очевидными, вид будет состоять только из строки ввода поискового запроса и списка твитов. Мы хотим достигнуть чего-то в этом духе:
<form>
    <input type="search" placeholder="Input #hashtag, @@username or something else" autofocus />
    <input type="submit" value="Search" />
</form>

<ul id="tweets">
    <li>
        <a href="[url-of-tweet]" title="[author-name]">
            <img src="[url-of-author-image]" alt="[author-name]" />
        </a>
        <h6>[author-name]</h6>
        <p>[tweet-text]</p>
    </li>
    <!-- More tweets... -->
</ul>


* This source code was highlighted with Source Code Highlighter.

Давайте также добавим немного декларативных Knockout привязок данных, которые свяжут вид и модель вида. Если мы немного модифицируем поисковую форму
<form data-bind="submit: search">
    <input type="search" data-bind="value: query, valueUpdate: 'afterkeydown'" placeholder="Input #hashtag, @@username or something else" autofocus />
    <input type="submit" value="Search" />
</form>


* This source code was highlighted with Source Code Highlighter.


…Knockout будет обновлять свойство запроса модели вида автоматически, когда пользователь изменит текст в поле поискового запроса, и поиск будет выполнен, как только пользователь нажмёт клавишу Enter.

Что насчёт поисковых результатов? Превосходный сценарий для использования Knockout привязки шаблонов! Во-первых, создайте шаблон для каждого твита:
<script id="tweetTemplate" type="text/html">
    <li data-bind="attr: { id: id }">
        <a data-bind="attr: { href: tweetUrl, title: author }">
            <img data-bind="attr: { src: imageUrl, alt: author }" />
        </a>
        <h6 data-bind="text: author"></h6>
        <p data-bind="text: tweetText"></p>
    </li>
</script>


* This source code was highlighted with Source Code Highlighter.

Затем data-bind свойство HTML списка твитов в модели вида, задавая ваш шаблон как способ для отображения каждого из них. Относительно легко:
<ul id="tweets"
    data-bind="template: {
                  name: 'tweetTemplate',
                  foreach: tweets
              }, visible: tweets().length > 0">
</ul>


* This source code was highlighted with Source Code Highlighter.

(Заметьте, что список не будет видимым, если модель вида не содержит твитов)

Бонус: Добавьте анимацию, которая используется, когда новые твиты добавляются в список, используя afterAdd опцию привязки шаблона:
<ul id="tweets"
    data-bind="template: {
                  name: 'tweetTemplate',
                  foreach: tweets,
                  afterAdd: function (element) {
                      $(element).hide().fadeIn('fast');
                  }
              }, visible: tweets().length > 0">
</ul>


* This source code was highlighted with Source Code Highlighter.

К этому моменту осталось доделать только одну вещь – логику начальной загрузки. Когда DOM загрузился, нам нужно создать экземпляр нашей модели вида и попросить Knockout присоединить наши привязки к ней. Одна строка кода в jQuery обработчике события “document ready”:
<script type="text/javascript">
    $(function () {
        ko.applyBindings(new SearchViewModel());
    });
</script>


* This source code was highlighted with Source Code Highlighter.

И сделано! Проверено на моей Windows 7 машине в Chrome 12, Internet Explorer 9 и Firefox 5. Здесь законченное решение (где я добавил немного CSS для того чтобы это выглядело менее ужасно): TwitterLiveSearchLab.zip
AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 17

    +8
    Использую Knockout в проекте и не могу сказать, что документация у него хорошая. Она неполная, нет того, что можно было бы назвать Reference. Но она завлекательная: классное видео с MIX11, где Стив Сандресон — автор фреймворка — за двадцать минут убедил меня, что MVVM — это именно то, что нужно на клиенте, а также заставил меня отказаться от Backbone, о чем, я, кстати, ни секунду не жалел. Примеры тоже впечатляют, есть ремарки на тему подписок — на каждое observable-поле можно подписаться — колбэк будет вызываться при изменении значения.

    Но проблемы тоже есть. Например, на этой неделе вскрылось, что JSON сериализатор в нокауте не умеет работать с датами (Date object) вообще — при сериализации получаем null. Кроме того, не всегда понятно, когда в аттрибутах data-bind нужно писать выражения в кавычках или добавлять скобки для observable полей — понимание приходит с опытом, но не без необходимости пробежаться дебаггером по исходникам Нокаута.

    Что еще? Темплейты не всегда подходят — иногда приходится собирать фрагменты DOM руками, и в этих случаях Knockout совсем не помогает, а может быть даже мешает, т.к. интеграция с ним требует дополнительных затрат кода.

    Ну и наконец, однажды написав data-bind=«click: someHandler», я задумался: а чем же это отличается от onclick, которого все так боятся и ненавидят?..
    Я сам так больше не пишу, а наличие клик-байндинга в фреймворке считаю ошибккой.
      0
      Спасибо вам за коммент, именно такой опыт особенно ценю, а не «завлекательные» видео с конференций.
        +3
        > заставил меня отказаться от Backbone, о чем, я, кстати, ни секунду не жалел

        Очень хочется большего раскрытия темы. Я вот написал небольшое приложение с Бекбоном и очень доволен; хочется услышать, что плохо и как бывает лучше.
          +1
          > а также заставил меня отказаться от Backbone, о чем, я, кстати, ни секунду не жалел.

          Очень хочется более подробного раскрытия темы. :)
            +4
            Млин, хабр, я тебя обожаю. Спасибо, что дал мне возможность запостить одно и то же дважды. 40 минут, конечно, недостаточно, чтоб показать мне мой первый комментарий. :(
              0
              20 минут, существенная разница ;)
                0
                14:03 и 14:40 между первым и вторым комментарием.
                +1
                Стал писать комментарий, который, я смотрю, стал розростаться до размеров статьи. Давайте я ее до ума доведу и опубликую на днях.
                  0
                  О, клëво. Боюсь только, что пропущу. Если будет не очень сложно, то какой-нибудь комментарий здесь со ссылкой было бы клëво получить (да и потом для референса будет видно, куда идти читать дальше :)).
            0
            Но проблемы тоже есть. Например, на этой неделе вскрылось, что JSON сериализатор в нокауте не умеет работать с датами (Date object) вообще — при сериализации получаем null.


            А формат JSON и не предполагает иметь Date object в качестве значений.
              0
              Не скажите. В JSON.stringify подазумевается, что если у объекта есть метод .toJSON(), то следует для сериализации использовать его. И в нативной реализации, и в json2.js такой метод определяется для прототипа Date [1]:

              github.com/douglascrockford/JSON-js/blob/master/json2.js#L175

              Кроме того, при десериализации в качестве второго параметра можно передать функцию-трансформер, которая может преобразовывать элементы json-объекта. В комментариях к методу как раз и указан пример правильной обработки дат:

              github.com/douglascrockford/JSON-js/blob/master/json2.js#L103

              Так что, хотя с точки зрения JSON как формата данных дат не существует, с точки зрения JSON как api браузеров даты есть. Я считаю, что это не проблема JSON, а проблема Knockout.

              [1] Да-да, Дуг Крокфорд тоже переопределяет прототипы!
                0
                И да, сериализатор дат работает в соответствии с ISO форматом, так что на сервере никаких проблем с разным поведением .toString() не возникает.
            +1
            Кстати, если уж на то пошло, зачем тут бэкенд вообще? JSONP и всë будет работать на стороне клиенте.
              0
              Замечательная статья.

              ps: И alist ждем…
                0
                А я (как обычно на скорую руку) собирал проект с KnockOutJS — запутался в bind-ах. Иногда срабатывала функция по имени, иногда — только по полному имени «click: SearchViewModel.search». До сих пор не ковырял :)

                В целом — очень понравилась библиотека, но, соглашусь с <a href=«alist.habrahabr.ru» class=«user_link>alist — иногда приходится „руками“ — и тогда приходится городить огороды.

                Кстати, сейчас вот, собираюсь засесть за observableArray — чтобы биндить переменные для данных, внутри данных, внутри данных (угу, вот такая засада) :)

                Backbone — отпугнул обилием кода ради простого действа (наткнулся на обзор на хабре).
                  0
                  ой, сорри за ссылку косую :(

                Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                Самое читаемое