Web2.1 или динамические WEB-приложений нового типа
kayo — Втр, 18/11/2014 - 19:05
Однажды мы уже решали проблему модернизации WEB-а. Тогда мне не удалось реализовать самую правильную идею: создать некий API для генерации содержимого, реализации которого будут созданы как для клиента, так и для сервера. Но времена изменились, и ныне проблема не актуальна.
Так больше жить нельзя
Как водится, всё упиралось отнюдь не в технические ограничения. Дело было в методологии, которая была в корне не верна. Обычно мы смотрим на приложение, как на нечто, управляемое событиями. Мы получаем событие, прокручиваем некую бизнес-логику, затем что-то меняем в представлении. И так повторяется снова и снова. При этом модификация представления превращалась в настоящий ад, поскольку нужно было предусмотреть все возможные и почти не возможные варианты изменений.
Потом появилась более правильная методология MVVM, которая выделила модель представления. Это дало возможность работать не с представлением непосредственно, а с некой моделью. Мы меняли модель, а представление синхронизировалось с её текущим состоянием. Казалось, ад позади, но технологии, которые реализовывали эту новую методологию, имели существенные недостатки:
- Все изменения происходили синхронно, это напрягало клиент
- Привязки обработчиков делались к конкретному элементу DOM дерева
Первая проблема состояла в резко возростающей нагрузке на клиент, если требовалось выполнить множество изменений одномоментно. Особенно это касалось KnockoutJS, приходилось применять специфические техники, чтобы частично снизить пагубные последствия для производительности, но это, в свою очередь, делало происходящее в коде весьма не очевидным.
Например, представим большую таблицу с пейджером. В модели таблица представлена наблюдаемым массивом (observableArray), с элементами-строками. Когда пользователь сдвигается вперёд, мы хотим показать последнюю строку предыдущей страницы первой строкой следующей, а далее пустить остальные строки. Что мы должны сделать? Удалить все строки кроме последней с начала наблюдаемого массива (shift), добавить новые строки в конец (push).
В ситуации, когда каждая операция с моделью вызывает соответствующее изменение представления, каждое наше действие приводит к вызову функций DOM API браузера. Фреймворк ничего не может поделать с этим, поскольку любая оптимизация вызовов может привести к несоответствию представления модели.
Разработчики AngularJS поступили по-другому, сделали обновление представления асинхронным. Через определённый интервал фреймворк выполняет грязную проверку (dirty check) модели, и обновляет представление. Это несколько разгрузило клиент, но не решило проблему окончательно. Да и сама по себе такая проверка весьма не быстрая вещь для больших моделей.
Вторая проблема вынуждает фреймворк порождать множество точек возникновения событий в DOM дереве. Так для нашей огромной таблицы, если мы разместим в каждой строке кнопку удаления этой строки, обработчик будет привязан к каждой кнопке.
Но плохо на самом деле другое, а именно то, что фреймворку приходится пересоздавать привязки через вызовы DOM API вместе с обновлением представления в то время, как лишь малая часть привязок будет реально использована. Так пользователь может вообще не удалить ниодной строки за весь сеанс, но фреймворк вынужден обслуживать привязки в любом случае.
Маленькая функциональная революция
Но если хорошо подумать, то всё выше описанное — лишь следствие того, что представление реализовано путём прямой работы с DOM API и порочности наших взглядов на работу приложения.
Явное состояние вместо модели отображения
Что вообще такое модель отображения? В теории отображение соответствует модели отображения, но как быть с теми частями модели, которые не имеют прямого отношения к отображению, но как-то влияют на него. Это наводит на мысль, что первично внутреннее состояние приложения, а модель отображения — лишь часть этого состояния.
Внутреннее состояние есть всегда. Даже когда приложение представляет собой лапшу из обработчиков и коллбэков, внутреннее состояние всё равно подразумевается, хоть явно и не присутствует. В этом случае оно размыто по множеству обработчиков, замыканий, и даже структуре DOM дерева. Но оно объективно существует.
Прогрессивная идея состоит в том, чтобы сделать это неявное состояние явным и работать непосредственно с ним. Этот подход лежит в основе методологии FRP, но даже сам по себе он весьма полезен. Отображение напрямую зависит от явного состояния, при изменении состояния, меняется отображение.
Неизменяемое состояние вместо изменяемого
Состояние представляет собой древовидную структуру, в конечных узлах которой находятся атомарные значения. Для корректного обновления отображения нам нужно выяснить, как изменилось состояние. Другими словами, если какая-то часть дерева изменилась, необходимо как-то поменять элементы отображения, зависящие от неё.
Сравнивать древовидные структуры в глубину — не очень быстрое занятие. А ведь ещё нужно их копировать, чтобы было что сравнивать на каждом шаге синхронизации. Гораздо быстрее было бы просто сравнивать ссылки на поддеревья, но это требует использования структур в неизменяемом стиле. Если ссылка на некоторую подструктуру изменилась, однозначно можно сказать, что внутри неё что-то изменилось, и для этого не нужно спускаться ниже.
Не все верно понимают сущность такого понятия, как неизменяемость. Неизменяемость вовсе не означает, что нельзя ничего делать с нашим деревом. Напротив, можно и нужно. Однако, для того, чтобы изменить некое значение, мы должны пересоздать все выше лежащие структуры.
Неизменяемость, на самом деле, очень простая вещь. Например, строки и числа в JS не изменяемые. Когда мы выполняем какие-то операции над строками или числами, в сущности мы создаём новые строки и числа как результат. С ранее созданными при этом ничего не происходит, они лишь вычищаются сборщиком мусора, будучи не используемыми, когда приходит время.
В то же время, объекты в JS мы можем менять, устанавливая новые значения полей. При этом объект остаётся всё тем же объектом. Идея в том, чтобы работать с объектами также, как с числами и строками. То есть создавать новый объект при изменениях. Подобъекты при этом переиспользуются, мы просто копируем ссылки на них.
Неизменяемость — очень полезное свойство, которое даёт простор для оптимизаций фреймворку. Кроме того, для некоторых приложений, которым требуется работать с историей действий, это позволяет дёшево реализовать механизм снэпшотов. Простой пример, реализация отмены в визуальном редакторе содержимого.
Виртуальное дерево DOM
Как мы помним, именно жесткая привязка к браузеру внесла затруднения в реализацию отображения средствами сервера. DOM — сама по себе весьма тяжелая и неповоротливая технология. Было бы слишком расточительно требовать от сервера работать с ней, когда нам просто нужно получить отображение, соответствующее некоторому состоянию.
Нужно нечто, что одинаково хорошо могло бы как взаимодействовать с DOM, так и работать в качестве обычного рендерера HTML. И такая методология есть и называется виртуальной DOM. Идея проста и элегантна. Нужно свести к минимуму и по возможности локализовать в конкретном месте в коде взаимодействие приложения с API браузера, потому что JS-код выполняется достаточно быстро, узким местом стали DOM биндинги.
Методология предполагает существование виртуального дерева документа в программе, которое периодически синхронизируется с реальным деревом документа в браузере. Фреймворк работает не с реальным DOM, а с этим деревом. Он всегда знает, что изменилось, и может вычислить минимальный набор вызовов DOM API для отражения этих изменений.
Этот процесс перерисовки возможно и желательно выполнять посредством requestAnimationFrame, чтобы работа нашего кода не перегружала браузер и в то же время была полностью незаметной для пользователя.
Этот подход решает проблему биндингов обработчиков. Больше не нужно привязываться к каждому элементу в реальном DOM дереве, достаточно отлавливать все события на корневом элементе, затем идентифицировать через свойство target объекта event, откуда событие пришло, и перенаправлять соответствующему обработчику в виртуальном дереве DOM.
Фреймворк ReactJS весьма удачно реализует эту концепцию.
Почему я назвал эту часть статьи «маленькая функциональная революция»? Просто потому, что изложенные в ней идеи пришли именно оттуда, из функционального программирования.
С этим пора кончать
На последок поговорим о том, что мы всегда делали плохо и с чем давно бы уже пора завязывать.
Серверные сессии
На заре развития веб технологий приложения работали в рамках концепции запрос-ответ. Не было возможностей сохранять состояние приложения между запросами, кроме как всякий раз возвращая клиенту некое значение, представляющее это состояние. Это значение формировалось как хэш от некого реального состояния, которое хранилось на стороне сервера. Иногда шифровали и подписывали это состояние целиком. В результате приходилось гонять туда-сюда большой кусок данных, который мог не меняться и не использоваться всеми запросами, но был необходим для сохранения состояния.
На смену пришло хорошо забытое старое — методология REST, которая является примером взаимодействия без управления состоянием клиента со стороны сервера. Каждый запрос содержит всю информацию о желаемом состоянии. Состояние клиента присутствует только на клиенте, тогда как сервер вообще не обязан знать, в каком состоянии находится клиент.
Сервер принимает запросы и изменяет своё внутреннее состояние, о котором клиент может узнать только из результата запроса. Не важно в каком состоянии находится клиент, результат запроса всегда однозначно определён внутренним состоянием сервера и самим запросом. Концепция взаимодействия запрос-ответ хорошо проверена временем, и REST наследует от неё всё лучшее.
REST приложения просты в реализации, надёжны, производительны и существенно лучше масштабируемы.
Печеньки (Cookies)
Чем так плохи куки? Может показаться, что я без причины на них ополчился. Но давайте не спеша подумаем, для чего они нам вообще нужны.
- Аутентификация пользователя
- Ведение сессии
Часто эти две вещи не отделяют друг от друга, а зря. Аутентификация (не путать с авторизацией) нужна, чтобы понять с кем из своих пользователей сервер имеет дело. А на сессиях мы уже останавливались выше. Если убрать сессии, которые на самом деле нам для взаимодействия с сервером не нужны, остаётся аутентификация.
Куки плохи тем, что они передаются в каждом запросе в неизменном виде, что лишь бесполезно засоряет канал даже когда мы запрашиваем статические ресурсы. А это существенно больше запросов, чем наши обращения к серверному REST API. Эту проблему частично решали размещением статики на поддоменах, для которых куки не устанавливаются. Но это ведь вовсе не то, что нам нужно.
Это свойство печенек гораздо хуже, чем кажется на первый взгляд. Кроме статики, далеко не все запросы требуют аутентификацию. Если результат запроса один вне зависимости от пользователя, аутентификационные куки серверу вовсе не нужны. Ровно как и не нужны промежуточным узлам, через которые проходит запрос. Но такие механизмы, как кэширование будут использованы крайне неэффективно, потому что теоретически результат запроса зависит от печенья, которые у пользователей различаются.
Ещё куки плохи тем, что они уязвимы для перехвата аутентификации. Достаточно прослушать соединение, и можно выполнять любые действия от лица перехваченного пользователя пока печенька аутентификации не устареет. А если она не устареет никогда?
Другой подход заключается в использовании специального параметра запроса или заголовка, в котором передаётся токен аутентификации. Этот токен не обязан быть постоянным, он может генерироваться клиентом на каждый запрос. Другими словами, мы можем некоторым образом подписывать запросы на стороне клиента или сделать этот токен быстро устаревающим, что существенно сузит возможности перехвата аутентификации и совершения каких-либо действий от лица пользователя.
Поддержка устаревших браузеров
Поддержка устаревших юзерагентов тянет нас назад, занимая время, которое могло быть с большей пользой потрачено на развитие новых возможностей. В связи с этим отказ от полной поддержки оных вполне закономерен. Также как закономерен отказ от нестандартных технологий, вроде внедренных Flash объектов и Java-апплетов, когда стандартные возможности покрывают задачи.
В то же время, некоторая минимальная поддержка обязана иметь место. Другими словами, пользователь приложения должен правильно понять две вещи:
- Факт отсутствия некоторой возможности
- Причину отсутствия этой возможности
Чтобы обеспечить первое, нам нужно явно дать понять, что некоторая возможность в данный момент не доступна. А чтобы обеспечить второе, нужно дать подсказки решения этой проблемы. Так во вчерашнем дне WEB-а внедрялись Flash объекты с помощью библиотек вроде SWFObject. Пользователь, у которого в браузере отсутствовал соответствующий плагин, видел сообщение со ссылкой на страницу плагина. Тогда как при внедрении обычным образом, пользователь видел то, что предусмотрено разработчиками браузера, а эти затычки никогда не были достаточно информативны.
Роль сервера
Как же меняется роль сервера в этом процессе модернизации WEB-а?
Изначально, когда число пользователей было небольшим, а приложения простыми, работа целиком была возложена на него. И это правильно с точки зрения безопасности и поддержки пользовательских агентов. Но это совершенно не верно с точки зрения удобства пользователя и скорости работы приложения. Поэтому данный подход в наши дни достоин осуждения и порицания.
С точки зрения специализации, выполнять построение графического интерфейса и взаимодействовать с пользователем — вовсе не задачи сервера. Это задачи пользовательского агента, и приложение должно быть разбито на две части — клиентскую и серверную.
В современном WEB-е роль сервера сводится к посредничеству между базой данных и клиентскими программами. Другими словами, сервер является арбитром, который обеспечивает корректную работу клиентов с данными. Но в отличии от базы данных, которая обеспечивает корректную работу с данными, сервер обеспечивает корректность бизнес логики, которая не может быть формализована посредством модели данных.
Когда клиентская часть приложения общается с серверной путём асинхронных REST запросов, существует множество честных и не очень способов ускорения отклика. Например, вовсе не обязательно напрягать пользователя ожиданием, когда для выполнения действия необходимо проделать некоторые длительные операции. В случае, когда результат запроса не зависит от результата этих действий, пользователь ничего не потеряет, если получит ответ до того, как операции будут завершены.
Да и сами принципы серверной разработки претерпевают существенные изменения под действием всё более ужесточающихся требований по времени отклика и числу одновременно обслуживаемых запросов. На смену потокам приходят лёгкие виртуальные нити исполнения (так называемые Fibers), которые делят между собой один поток и позволяют повысить эффективность использования памяти и других ресурсов, используя преимущества многопроцессорных систем. Но это уже совсем другая история.

Отправить комментарий