Архивы: по дате | по разделам | по авторам

Блеск и нищета клиент-серверных технологий. Часть II

Архив
автор : Андрей Акопянц   22.06.1999

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


Организация пользовательского интерфейса

Пользовательский интерфейс хорошей настольной системы почти всегда выглядел следующим образом: очень длинный список записей, представленный в табличном виде (grid, datasheet), в котором имеются поля из некоторой "основной" таблицы базы данных и таблиц, с ней связанных (например, список складских проводок, в которых видны названия товаров и получателей, хранящихся в отдельных таблицах). Таблица некоторым образом отсортирована (по дате/покупателю/товару). Мы стоим на некоторой позиции в этой таблице.

Этот список можно листать вверх-вниз и искать нужное, причем для названий поиск осуществляется путем набора начальных символов и позиционирования в списке по ходу набора. Результатом поиска является перемещение на нужную запись (записи).

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

Иногда возникает потребность отфильтровать нужные записи. Минимумом тут является фиксированная форма задания условия отбора, максимумом - возможность задания произвольного фильтра в стиле QBF (Query by form - условия вводятся прямо в карточке записи) или в виде "набора" условия в табличном виде из имен полей и ограничений на них.

Так вот, буквально все описанные выше элементы пользовательского интерфейса создают проблему при работе в клиент-серверной архитектуре.

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

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

- понимает, что видит неполный список (Где запись, которая - я точно знаю - была? Караул! База испортилась, в программе вирус и проблема 2000 года!);

- видит, каким именно условием ограничен текущий список;

- понимает, как изменить это условие, и имеет возможность сделать это легко.

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

Следующей проблемой, тесно связанной с предыдущей, является позиция. Дело в том, что SQL-сервер никакой "позиции" не знает. Таким образом, для того чтобы позиционироваться в списке записей, нам нужно тупо пролистать его до нужного места. Существует альтернатива: дать серверу такой запрос, который выберет "окрестность" искомой записи. Но тогда немедленно возникнут проблемы при попытке выйти из этой окрестности вверх или вниз. Это факт может быть несколько замаскирован, но понимать его нужно.

Здесь нужно сказать следующее: практически во всех системах разработки этот самый "список" может базироваться как на таблице базы данных, так и на SQL-запросе. Несмотря на внешнее сходство, эти два варианта сильно различаются по той функциональности, которую для них обеспечивает инструментарий разработки, и по своему поведению.

В случае работы с таблицей система, как правило, всеми силами старается имитировать стиль работы, характерный для настольных СУБД. Предоставляются понятия индекса, возможность искать по ключу и др. При работе с таблицей, как правило, обеспечивается удовлетворительная работа и с неограниченной выборкой (с точностью до поведения сервера), и с позиционированием (см. дальше). Операции редактирования данных имеют очевидную семантику. Переходы в начало/в конец/на выбранную позицию списка происходят достаточно быстро.

И всем этот вариант хорош, кроме одного: для "подтягивания" полей из других таблиц приходится пользоваться механизмом вычислимых полей и поштучно доставать их из базы. А это создает большой поток мелких запросов к серверу, что, как мы уже обсуждали, сажает как сервер, так и сеть. Визуально это проявляется в том, что перерисовка экрана при скроллировании вверх-вниз становится очень медленной, а время от времени просто затыкается.

При работе с SQL-запросом по нескольким таблицам (joined query) все меняется. Первый экран записей выдается, как правило, достаточно быстро, и скроллирование выглядит гораздо веселее, но... Попытка перейти в конец списка или вывести счетчик числа записей может занять минуты. Если же вы попросили сортировку, суммирование или группировку, минуты может занять и появление первого экрана.

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

Дело в том, что редактирование "составной" записи - дело логически сложное. Рассмотрим, например, нашу складскую проводку. Предположим, что мы выдали ее представление, в котором есть дата, количество, цена, название и адрес фирмы и название товара. Если мы исправим цену, то более или менее понятно, что делать - править цену в исходной проводке; если мы исправили адрес фирмы, тоже понятно - фирма переехала. А вот если мы исправили название товара? Что это - товар переименовали, или мы не тот товар вбили и теперь исправляем? А если товара с таким названием нет - ошибка это или его нужно добавить?

В разных системах разработки отношение к этой проблеме разное. В Delphi вопрос решен радикально: она честно считает, что результат запроса по нескольким таблицам вообще не редактируем. В Delphi 1.0 эта проблема не решалась никак, а в версии 2.0 и выше появилась возможность самим описать, что мы понимаем под редактированием такой записи, - механизм отложенных изменений. Концептуально это правильно, только сложно очень... Я, достаточно хорошо зная Delphi, провозился часа три, чтобы заставить этот механизм работать. Правда, благодаря высокой инструментальности Delphi, можно, решив эту проблему для некоего случая один раз, оформить результат таким образом, чтобы в дальнейшем проблема решалась одним движением руки. Не понятно, почему это не сделали разработчики, наверное, не успели - торопились выпустить очередной релиз.

Еще веселее с Access. Он, как все "мелкомягкие" продукты, считает себя самым умным и разрешает редактировать результат запроса без ограничений! Правда, что при этом творит! Например, в приведенном выше примере он без малейших сомнений и колебаний изменит название товара в таблице товаров. Если добавить запись и ошибиться в названии фирмы на один символ, он также уверенно добавит запись в таблицу фирм. Как он разбирается, что нужно удалять в этом случае, для меня загадка... Можно, конечно, запретить редактирование, но вот можно ли Access заставить вести себя полностью корректно даже в частном случае, я сомневаюсь.

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

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

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

И, что характерно, сам перенос занял месяц, и еще два месяца продолжалась борьба за скорость работы, так как заказчик (странно, не правда ли) требовал, чтобы новая система работала, по крайней мере, не медленнее, чем старая...

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

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

А здесь сразу выясняется, что этот запрос, кроме условия, наложенного пользователем, должен содержать еще некоторую предопределенную часть, описывающую не фильтрованный запрос, и их нужно соединить вместе. Заниматься синтаксическим анализом имеющегося SQL для того, чтобы вставить в него новые условия фильтрации и порядок сортировки, очень не хочется.

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

К пониманию необходимости динамического порождения SQL-запросов рано или поздно приходят все разработчики, кроме разработчиков базового инструментария. Видимо, им самим не приходится пользоваться своими творениями.

Многопользовательская работа

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

Это сильно поднимает требования к программе. Она должна быть правильно спроектирована и написана практически без ошибок. Выясняется также, что многие достаточно хорошие программисты не в состоянии быстро освоить "параллельный" способ мышления, необходимый для разработки подобных приложений.

Специфика многопользовательской работы с данными заключается в том, что в качестве механизмов управления межзадачным взаимодействием выступают средства СУБД, то есть транзакции и блокировки, что не снижает значимости предыдущего замечания. Я не буду описывать принципы, которыми руководствуются разные серверы баз данных в своей работе, это предмет обширной специальной литературы. Но некоторые общие соображения и типичные примеры, мне кажется, будут полезны.

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

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

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

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

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

Если учесть, например, что в MS SQL до версии 6 включительно блокируется не запись, а страница, то есть некоторая совокупность записей, которым выпало оказаться рядом с редактируемой, и то, что при неудачной последовательности блокировок легко организовать клинч, то станет ясно, что политику блокировок нужно продумывать очень тщательно и редко когда в серьезном приложении удается обойтись без некоторой своей надстройки над штатной системой блокировок.

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

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

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

Рассмотрим тот же складской пример. В нем единой логической операцией нужно делать исполнение складской проводки с заданными параметрами. Эта операция должна пытаться выполнить проводку независимо от значения остатка и оканчивается неудачей в случае, если товар на складе кончился. Причем операция эта должна быть отделена от ввода параметров проводки и первичной проверки наличия товара (которая, как мы видим, ничего не гарантирует).

Еще один класс проблем вызывают долгие массовые операции. Они обычно плохо сосуществуют с текущими короткими транзакциями и вызывают некоторые технические и логические проблемы, различные для разных серверов.

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

Длинные отчеты, не меняющие данные, но привязанные к определенному моменту времени, также нужно защищать. Если мы хотим получить состояние склада на 14:00, а соответствующий отчет генерируется полчаса, то за это время в систему введут еще десяток проводок, и мы получим отчет, не соответствующий никакому моменту времени.

Блокировать данные в таких случаях - слишком суровая мера. Почти все серверы позволяют задать режим isolation level, при котором открытая транзакция "не видит" данных, измененных другими транзакциями. Фактически это означает, что для данной транзакции создается персональная копия тех данных, с которыми она имеет дело. Хотя это и прозрачно для пользователя, нагрузку на сервер создает почти такую же, как и массовая модификация.

Неприятные технические последствия, к которым приводят массовые операции, зависят от особенностей сервера.

Interbase (простите, IB Database), например, при изменении записи оставляет ее предыдущую копию, которая живет, пока есть хотя бы одна использующая ее транзакция. Потом эти копии становятся мусором, который нужно ликвидировать специальной операцией сжатия. Сжатия эти начинаются периодически по некоторым правилам, и если были массовые операции, сильно подсаживают сервер.

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

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

Инструментальность средств разработки

По уровню базовой функциональности все основные системы разработки клиент-серверных приложений идут "голова в голову". Поэтому важнейшей их характеристикой является величина порога, который нужно преодолеть для реализации функций, выходящих за рамки базовых возможностей. Есть системы, где "шаг в сторону считается побегом", в других - всякие не предусмотренные разработчиками вещи делаются достаточно просто, а главное - просто интегрируются со всем остальным. Я, к сожалению, не знаю, как это свойство обозвать одним словом.

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

Зато для следующей, также принципиально важной характеристики у меня слово есть. Под инструментальностью я понимаю меру того, в какой степени возможно реализованные нестандартные решения компактно оформить для дальнейшего повторного использования. Это свойство сильно зависит от особенностей используемого разделения системы на компоненты и инструментальности языка разработки.

По сочетанию этих параметров наилучшим из известных мне средств является Delphi, а наихудшим - MS Access (до версии 7 включительно).

К разработкам на базе Visual Basic или С++ эти критерии просто неприменимы: там базовые возможности настолько низкого уровня, что порог начинается почти сразу, и преодолевать его приходится за счет высокой инструментальности С++ как языка программирования, разрабатывая свои библиотеки... На Бейсике это, говорят, тоже стало возможно, но что-то слабо верится. Генотип у него плохой - ламерский.

Заключение

На самом деле набор проблем и тонких мест характерен для каждого сочетания "среда разработки + сервер БД". Перечисленные проблемы встречаются почти везде. В некоторых вариантах имеется еще и свои "тараканы".

Как вы думаете, кто такой специалист по Oracle? Человек, выучивший эту среду программирования? Нет! Любой грамотный программист выучит ее в необходимом объеме за месяц. Специалист по Oracle - это человек, знающий, как ее правильно настроить под особенности конкретной задачи. Специалист по MS SQL - это человек, твердо знающий, какими средствами нельзя пользоваться никогда и как обходиться без них.

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



© ООО "Компьютерра-Онлайн", 1997-2021
При цитировании и использовании любых материалов ссылка на "Компьютерру" обязательна.