Ajax. Новое слово в разговоре клиента и сервера::Журнал СА 9.2006
www.samag.ru
     
Поиск   
              
 www.samag.ru    Web  0 товаров , сумма 0 руб.
E-mail
Пароль  
 Запомнить меня
Регистрация | Забыли пароль?
Журнал "Системный администратор"
Журнал «БИТ»
Наука и технологии
Подписка
Где купить
Авторам
Рекламодателям
Архив номеров
Контакты
   

  Опросы

1001 и 1 книга  
19.03.2018г.
Просмотров: 9866
Комментарии: 0
Потоковая обработка данных

 Читать далее...

19.03.2018г.
Просмотров: 8078
Комментарии: 0
Релевантный поиск с использованием Elasticsearch и Solr

 Читать далее...

19.03.2018г.
Просмотров: 8182
Комментарии: 0
Конкурентное программирование на SCALA

 Читать далее...

19.03.2018г.
Просмотров: 5183
Комментарии: 0
Машинное обучение с использованием библиотеки Н2О

 Читать далее...

12.03.2018г.
Просмотров: 5857
Комментарии: 0
Особенности киберпреступлений в России: инструменты нападения и защита информации

 Читать далее...

Друзья сайта  

 Ajax. Новое слово в разговоре клиента и сервера

Архив номеров / 2006 / Выпуск №9 (46) / Ajax. Новое слово в разговоре клиента и сервера

Рубрика: Веб /  Веб

АЛЕКСЕЙ МИЧУРИН

Ajax
Новое слово в разговоре клиента и сервера

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

Можно ли описать целую технологию в журнальной статье? Наверно, нет. Но нам повезло, ведь...

...Ajax – это не технология

Удивлены? Давайте разберёмся, из чего же складывается Ajax.

Это смесь технологий, которые уже всем хорошо знакомы:

  • CSS (Cascading Style Sheets) – набор средств для описания внешнего вида HTML-конструкций.
  • DOM (Document Object Model) – формальное представление HTML- или XML-документа, позволяющее управлять его элементами: создавать, удалять, изменять свойства.
  • JavaScript – язык – двигатель первых двух технологий.

Знакомое сочетание? Конечно! Это пресловутый DHTML. Ajax дополняет DHTML всего одной возможностью – обращаться к серверу по HTTP и обрабатывать полученный запрос.

Это в корне меняет дело. DHTML способен «оживить» страницу, но он может оперировать только с теми данными, которые были загружены вместе со страницей. Ajax позволяет разработчику обратиться к серверу, получить новые данные и, пользуясь уже существующими возможностями DHTML, интерпретировать эти данные и изменить страницу. При этом перезагрузки страницы не происходит, новые данные «подкачиваются» на уже открытую страницу.

Эту новую возможность должно отражать и само название Ajax – сокращение от Asynchronous JavaScript and  XML.

Описать CSS, DOM и JavaScript в одной статье, конечно, невозможно, но вот рассмотреть то, что отличает DHTML от Ajax, вполне реально. Этим мы и займёмся. Для рассмотрения предлагаю конкретный пример.

Ajax-приложение

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

Рисунок 1. Веб-страница сразу после загрузки

Рисунок 1. Веб-страница сразу после загрузки

После того, как он введёт число и нажмёт на кнопку, страница будет скорректирована (без перезагрузки) и примет следующий вид (см. рис. 2).

Рисунок 2. Веб-страница после нажатия на кнопку

Рисунок 2. Веб-страница после нажатия на кнопку

Давайте посмотрим на код «с высоты птичьего полёта». Он складывается из двух составляющих: клиентской, загружаемой в браузер, и серверной, обрабатывающей Ajax-запросы. Ajax-запросами, для краткости, я буду называть запросы, сгенерированные средствами Ajax. Конечно, с точки зрения сервера, это обычные HTTP-запросы.

На стороне клиента

Приведу сразу весь код:

<html>

<head><title>Test Ajax</title></head>

<script>

function set_inner(e, v) {

  b=document.getElementById(e);

  b.innerHTML=v;

}

function process_response(r) {

  set_inner('step', r.readyState);

  if (r.readyState == 4) {

    set_inner('status', r.status);

    text = r.responseText;

    strings = text.split('\n');

    set_inner('time', strings[0]);

    set_inner('qstring', strings[1]);

    set_inner('xsq', strings[2]);

  }

}

function prepare_http_object() {

  r = false;

  if (window.XMLHttpRequest) { // Mozilla, Safari, Opera ...

    r = new XMLHttpRequest();

  } else if (window.ActiveXObject) { // IE

    r = new ActiveXObject("Microsoft.XMLHTTP");

  }

  return r;

}

function setup_http_object(r) {

  r.onreadystatechange = function() {process_response(r);};

}

function send_http_require(r) {

  r.open('GET', 'calc.php?x='+escape(document.frm.x.value), true);

  r.send(null);

}

function do_http_rq() {

  r=prepare_http_object();

  setup_http_object(r);

  send_http_require(r);

}

</script>

<body>

<form name="frm">

Данные для запроса:<br>

x = <input type="text" name="x" size="3">

<input type="button" value="отправить запрос"

       onclick="do_http_rq();"><br>

Ход выполнения запроса: <b id="step">null</b><br>

Статус ответа: <b id="status">null</b><br>

Результат разбора ответа:<br>

время: <b id='time'>null</b><br>

строка запроса: <b id='qstring'>null</b><br>

x-квадрат: <b id='xsq'>null</b>

</form>

</body>

</html>

Это статическая HTML-страница, которую я назвал незамысловато – index.html. Как видите, она почти полностью состоит из JavaScript-кода. Это и не удивительно, ведь мы создаём не просто HTML-страницу, а Ajax-приложение.

Небольшая HTML-составляющая кода не блещет никакими изысками. Все элементы, которые будут изменяться, я выделил жирным шрифтом (тег <b>) и назначил им уникальные идентификаторы, по которым буду на них ссылаться. Для наглядности, сразу после загрузки страницы (до выполнения Ajax-запросов) все динамические элементы заполнены строками «null». Это видно на рис. 1.

На стороне сервера

На стороне сервера я предлагаю разместить PHP-сценарий следующего содержания:

<?

header('Content-type: text/xml');

header('Cache-Control: no-cache');

echo date("M d Y H:i:s\n", time());

echo ('<font color="red">'.$_SERVER['REQUEST_URI']."</font>\n");

$x=intval($_REQUEST['x']);

$xx=$x*$x;

echo "$x&sup2;=$xx\n";

?>

Он будет обслуживать запросы, формируемые Ajax-сценарием.

Думаю, что даже человек, не знакомый с PHP, легко разберётся в том, что делает этот сценарий. Сперва он передаёт два HTTP-заголовка:

Content-type: text/xml

Cache-Control: no-cache

Первый из них весьма важен. Некоторые браузеры не обрабатывают HTTP-ответ, если MIME-тип не text/xml. Эту проблему можно решить и средствами JavaScript на стороне клиента (к этому мы ещё вернёмся), но «бережёного бог бережёт». Второй заголовок тоже рекомендуется добавлять. Он, как вы понимаете, предотвращает кэширование передаваемой информации.

После заголовка мы формируем тело ответа, которое состоит из трёх строк следующего вида:

Aug 18 2006 13:09:28

<font color="red">/ajax/calc.php?x=4</font>

4&sup2;=16

Первая – дата (по часам сервера). Вторая – строка запроса, переданная клиентом, оформленная HTML-тегами. Когда она будет отображаться на HTML-странице, теги будут корректно интерпретированы браузером, как вы уже видели на рис. 2. Точно так же можно передавать не только элементарное форматирование, но и таблицы, формы, теги img и прочее. Третья строка содержит ответ на вопрос – параметр запроса x, возведённый в квадрат.

Обратите внимание, Ajax не делает веб-сервер более защищённым. Он никак не расширяет возможности HTTP-протокола. Чтобы избежать неприятностей, мы использовали функцию intval, гарантированно представляющую аргумент в виде целого числа.

Следует оговорить и ещё одну возможность, которую Ajax-приложения эксплуатируют очень часто (в нашем примере мы её не используем). Дело в том, что программно генерировать ответ необходимо не всегда. Часто достаточно хранить на сервере статические файлы с данными, которые будут подгружаться по мере (и в случае) необходимости. Следует только позаботиться о правильном MIME-типе, который обычно можно задать в файле .htaccess директивой:

AddType text/xml .xml

После этого все файлы с расширением xml (можно выбрать и любое другое расширение) будут выдаваться с верным типом.

Как это всё работает

Давайте теперь перейдём к подробному рассмотрению JavaScript – именно он заставляет работать всю эту «машину».

После того как пользователь нажимает на веб-странице магическую кнопку «отправить запрос», вызывается функция do_http_rq, которая по очереди совершает три основные операции:

  • создаёт объект запроса (функция prepare_http_object);
  • инициализирует объект нашего запроса (функция setup_http_object);
  • осуществляет запрос (функция send_http_require).

Внимательный читатель спросит: «А кто же обрабатывает результат запроса?». Давайте рассмотрим всё по порядку.

Создаём объект запроса

За создание объекта запроса у нас отвечает функция:

function prepare_http_object() {

  r = false;

  if (window.XMLHttpRequest) { // Mozilla, Safari, Opera ...

    r = new XMLHttpRequest();

  } else if (window.ActiveXObject) { // IE

    r = new ActiveXObject("Microsoft.XMLHTTP");

  }

  return r;

}

Это единственное место, где мы сталкиваемся с несовместимостью браузеров. Все браузеры создают объект запроса функцией XMLHttpRequest, и только Microsoft предлагает использовать альтернативный подход – ActiveXObject("Microsoft.XMLHTTP"). Любопытно, что при этом создаётся точно такой же объект, но должен же был Microsoft внести свой вклад.

Обратите внимание, если браузер не поддерживает ни XMLHttpRequest, ни ActiveXObject, то наша функция prepare_http_object возвращает false. Эту ситуацию не плохо было бы обработать в do_http_rq. Например так:

function do_http_rq() {

  r=prepare_http_object();

  if (r) {

    setup_http_object(r);

    send_http_require(r);

  } else {

    alert('Эта страница не может быть корректно отображена вашим браузером');

  }

}

Я этого не сделал, только чтобы не перегружать код деталями. Здесь уместно вспомнить о том, что некоторые браузеры (например Firefox 1.5 и более поздние(?)) не обрабатывают ответы с типом, отличным от text/xml. Лучше и надёжнее, конечно, решать эту проблему средствами сервера, как мы это делали выше, но если ситуация безвыходная, то в код следует добавить инструкции принудительного корректирования MIME-типа:

function prepare_http_object() {

  r = false;

  if (window.XMLHttpRequest) { // Mozilla, Safari, Opera ...

    r = new XMLHttpRequest();

    if (http_request.overrideMimeType) {

      http_request.overrideMimeType('text/xml');

    }

  } else if (window.ActiveXObject) { // IE

...

Как видите, мы установили MIME-тип методом overrideMimeType. Для браузера Internet Explorer этого делать не надо.

Если ваш сервер правильно настроен и выдаёт корректный MIME-тип, то необходимости в этом дополнении, конечно, нет вообще.

Настраиваем обработчик ответа

После того как объект соединения создан, его следует настроить. Для этого у нас предусмотрена функция setup_http_object. Единственное, что она делает – назначает функцию-обработчик ответа. Саму эту функцию мы рассмотрим ниже, а здесь коснёмся способов её указания.

Самый простой способ – по имени (или по ссылке):

obj.onreadystatechange = function_name;

Обратите внимание, что после имени функции не следует указывать скобки и аргументы, иначе произойдёт обычный вызов.

Можно задать обработчик, не создавая именованной функции:

obj.onreadystatechange = function() {

  // делаем что-то

}

Так мы создадим анонимную функцию и присвоим ссылку на неё свойству obj.onreadystatechange.

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

Чтобы решить эту проблему, обычно применяется один из двух приёмов.

Первый – хранить объект запроса в глобальной переменной. Это очень дурная (хотя и распространённая) практика. Если во время выполнения одного запроса пользователь снова нажмёт на кнопку, то глобальная переменная, хранящая объект первого запроса, будет изменена. Теперь, если будет принят первый запрос, обработчик запроса получит ссылку на второй, который ещё, может быть, не выполнился. Всё это неминуемо приведёт к очень неприятным, плохо воспроизводимым, ошибкам.

Второй подход как раз применяется в моём коде:

function setup_http_object(r) {

  r.onreadystatechange = function() {process_response(r);};

}

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

В данном случае возникает ситуация, называемая в программировании «замыкание» (closure): локальная переменная r исчезает после завершения работы функции setup_http_object, но её значение сохраняется, так как данные продолжают использоваться в анонимной функции, которая, в свою очередь, не исчезнет, пока существует сам объект r. То есть сам же объект r становится гарантом своей вечной жизни, и при этом он оказывается недоступен для основной программы. Единственное место, где можно оперировать с r, – тело функции process_response.

Подобные ситуации всегда неминуемо ведут к утечкам памяти. Немногочисленные тесты, проведённые мной, показали, что так оно и есть. FireFox на разных платформах показал примерно один и тот же результат – утечка около 600-700 байт на один запрос. Internet Explorer оказался куда более расточительным: на каждый запрос он тратит около 1700 байт и один системный дескриптор. Opera «кушает» примерно по килобайту на запрос, но есть основания полагать, что Opera всё же «освобождает» паять. Она не возвращает память системе, но, возможно, использует её повторно.

Замыкания не являются редкостью, но классические методы борьбы с ними в данном случае не работают. Так в любом высокоуровневом языке с автоматическим управлением памятью достаточно было бы создать «деструктор» вида:

function unclose(r) {

  r.onreadystatechange = function() {};

}

Он бы разорвал замкнутый круг взаимных зависимостей (разомкнул замыкание), и переменная была бы уничтожена системой автоматически. Использование такого приёма, к сожалению, не даёт никаких результатов ни на одном браузере. Более того, в FireFox утечка памяти происходит даже в отсутствии замыканий! Да-да, даже если использовать глобальные переменные, каждый вызов XMLHttpRequest() навсегда съедает половину килобайта оперативной памяти. Очевидно, язык JavaScript ещё есть куда улучшать, если он не справляется с такими простыми задачами.

Есть и ещё одна плохая новость: браузеры освобождают память (а IE и дескрипторы) только в случае закрытия окна. Никакие переходы на другие страницы или закрытия вкладок не вызывают очистку памяти.

Всё это весьма прискорбно, однако, я нигде не видел и не слышал о том, что утечки памяти являются серьёзной проблемой JavaScript-приложений. Все источники сходятся во мнении, что использование глобальных дескрипторов (да и любых других переменных) – гораздо большее зло, чем замыкания и трудности, связанные с ними.

Чтобы покончить с вопросами сбережения памяти, давайте оценим, какова реальная угроза. Допустим, на определённое действие пользователя ваше приложение совершает три запроса (надо сказать, это должно быть весьма и весьма развитое приложение). Пусть пользователь работает достаточно активно и совершает указанное действие один раз в минуту. Ощущать дефицит памяти он начнёт где-то после 500 000-1 000 000 операций. Несложный подсчёт показывает, что на это ему понадобятся месяцы непрерывной работы. В свете результатов этого нашего воображаемого эксперимента проблема утечек памяти уже не кажется столь зловещей. Просто о ней надо помнить, если вы решите делать запросы в циклах по тысяче штук за раз.

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

obj.setRequestHeader('Content-Type',

    'application/x-www-form-urlencoded');

Итак, мы создали и инициализировали объект запроса. Самое время сделать запрос на сервер.

Посылаем запрос

Запрос мы посылаем функцией do_http_object. Она выполняет всего два действия:

  • открывает поток методом open;
  • запускает процесс обмена данными методом send.

Open получает три входных параметра: HTTP-метод, адрес и флаг асинхронности.

r.open('GET', 'calc.php?x='+escape(document.frm.x.value), true);

Имя метода следует всегда писать большими буквами: не все браузеры станут преобразовывать регистр, а по протоколу HTTP имя метода должно передаваться именно заглавными буквами.

Адрес может быть и абсолютным, и относительным, но я бы советовал не использовать полных адресов с явным указанием имени сервера. Браузеры должны допускать Ajax-обращения только к тому серверу, с которого был загружен документ. Это приводит к массе недоразумений при наличии синонимов типа www.google.ru и просто google.ru (без www). В нашем примере мы генерируем адрес вида:

/ajax/calc.php?x=4

Обратите внимание, для кодирования передаваемых данных мы использовали функцию escape. В нашем случае это скорее дань хорошему тону, но при передаче более сложных данных эта мера становится жизненно необходима.

Третий аргумент интерпретируется как логическое значение. Если он равен «истина», то запрос производится асинхронно (это и есть буква A в аббревиатуре Ajax). То есть браузер не ждёт, когда отработается запрос, и продолжает выполнять JavaScript-код.

Метод send предназначен для отправки тела запроса. В нашем случае выполняется GET-запрос и тело пусто, но в случае POST-запроса тело передаётся как раз этим методом.

Обрабатываем результат

Итак, при обработке запроса вызывается функция process_response. Её единственным аргументом является объект запроса.

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

  • 0 – инициализация запроса;
  • 1 – формирование запроса;
  • 2 – формирование запроса окончено;
  • 3 – взаимодействие с сервером;
  • 4 – взаимодействие завершено (ответ получен).

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

Код 0 не используется большинством браузеров. Это начальное состояние.

Вызов с кодом 1 обычно происходит дважды. Согласно спецификации от W3C, код 1 должен отрабатываться по завершении open(), однако, в реальной жизни все вызовы с этим кодом происходят уже после вызова send.

Вызов с кодом 2 не повторяется, а Opera не генерирует вызов с кодом 2 вовсе. По спецификации он знаменует конец отправки браузером запроса и переход на приём.

Код 3 может поступать много раз по мере получения данных от сервера; причём разные браузеры вновь ведут себя не одинаково: Internet Explorer не повторяет код 3 (он вообще всегда выдаёт одну и ту же последовательность 1-1-2-3-4), а FireFox повторяет его на каждые четыре переданные килобайта. Согласно спецификации этот код может поступать на обработку несколько раз, но, по замыслу разработчиков, он несёт гораздо более глубокий смысл: первый вызов – принят заголовок, второй вызов – принято тело документа.

В большинстве случаев требуется обработать только ситуацию 4, и, к счастью, это, пожалуй, наиболее однозначный и надёжный код.

В первой строке мы выводим значение r.readyState в элемент с id=step; в HTML-коде ему соответствует подпись «Ход выполнения запроса». (Для этого используется функция set_inner.)

Во второй строке мы проверяем, завершён ли запрос, и если «да», то выполняем серию нехитрых действий:

  • получаем код HTTP-ответа методом status и «выкладываем» его в тело документа (в элемент status – «Статус ответа»);
  • получаем тело ответа методом responseText;
  • разбиваем его на строки методом split;
  • «выкладываем» три полученные строки, соответственно, в элементы time («время»), qstring («строка запроса»), xsq («x-квадрат»).

Как видите, всё, переданное сервером, вставляется в документ «как есть». При этом корректно интерпретируются специальные символы («&sup2;») и HTML-теги (<FONT>).

Данные, переданные сервером в HTTP-ответе, можно получить двумя методами:

  • r.responseText возвращает тело ответа без каких-либо изменений – просто как строку;
  • r.responseXML возвращает объект XML-документ, предоставляющий программисту массу методов для манипулирования данными (типа getElementsByTagName).

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

«За» и «против» Ajax

Достоинства Ajax не нуждаются в рекламе. Эта «технология» открывает необозримые горизонты перед веб-программистом и позволяет создавать сколь угодно сложные и динамичные интерфейсы. Но и недостатки Ajax трудноскрываемы. Давайте остановимся на них подробнее.

Фундаментальным «дефектом» Ajax является то, что она нарушает саму концепцию веб-пространства. Ведь изначально HTTP разрабатывался как протокол передачи данных, а браузеры – как средства отображения данных. Ajax же, являясь вершиной эволюции Web, окончательно превращает веб-документ в приложение, а браузер – в среду выполнения этого приложения. Эта «придирка» может показаться слишком надуманной, но все недостатки Ajax проистекают именно из этого нарушения философии Web.

Ajax нарушает основную концепцию адресации документов. Нарушается уникальность адресов: по одному и тому же адресу можно увидеть разную информацию. Это не только делает невозможным реализацию элементарных функций браузера, начиная с использования кнопки «назад», заканчивая возможностью создания закладки, но и делает в принципе невозможным индексацию документов поисковыми системами или даже элементарный обмен ссылками, когда друг «кидает» другу ссылку по «аське». (Конечно, пока ни один поисковик не в состоянии выполнить Ajax-код и правильно проиндексировать ресурс.) В деле разрушения соответствия URL-документ Ajax продвинулся гораздо дальше, чем фреймы, о вреде которых уже много написано.

Ну и, конечно, нельзя обойти вниманием пресловутую несовместимость браузеров. В этой статье мы видели её только в одном месте, но это во многом от того, что мы практически не использовали DOM, реализация которого в разных браузерах весьма и весьма различна. Причём, добиться уровня совместимости, необходимого для использования Ajax, будет не просто. Не только потому, что это просто сложно технически, но ещё и потому, что придётся пересмотреть основные механизмы браузеров – программ, которые создавались для просмотра документов, и от которых теперь требуется стать средами выполнения приложений. Косметическими улучшениями здесь не отделаться.

Кроме того, Ajax базируется на технологиях, которые не предназначались для решения тех задач, на которые он направлен. Поэтому Ajax порождает массу неоднозначностей и противоречий. Например, как должен реагировать браузер, если вместо обычного HTML-содержимого в страницу будет встраиваться новый JavaScript-код? Все эти противоречия порождаются той путаницей между данными и управляющим кодом, о которой я говорил в самом начале. Думаю, и браузеры, и технологии должны полностью переродиться, чтобы возможности Ajax стали универсальными и безопасными. Сейчас мы наблюдаем первых ласточек, которые возвещают о грядущих больших переменах в Web.

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

  1. Прежде всего, мне хотелось бы отметить MDC (Mozilla Developers Center) – http://developer.mozilla.org/en/docs/AJAX. Информации там не очень много, но она очень качественная. Нет никаких недомолвок, не лоббируются интересы какого-либо одного производителя браузеров, язык очень прост и понятен.
  2. Большой «развал» ссылок на различные ресурсы для Ajax-разработчиков можно найти здесь – http://www.maxkiesler.com/index.php/weblog/comments/451.
  3. Спецификацию метода XMLHttpRequest можно найти тут – http://www.w3.org/TR/2006/WD-XMLHttpRequest-20060405.
  4. Русский перевод описания протокола HTTP (RFC 2068) можно найти здесь – http://www.lib.ru/WEBMASTER/rfc2068.
  5. Кроме того, «Системный Администратор» уже публиковал две статьи об Ajax (№12 за 2005 г. и №6 за 2006 г.), где так же приводились интересные ссылки по этой теме. – http://www.samag.ru/cgi-bin/go.pl?q=articles;n=12.2005;a=13http://www.samag.ru/cgi-bin/go.pl?q=articles;n=06.2006;a=10.

Комментарии отсутствуют

Добавить комментарий

Комментарии могут оставлять только зарегистрированные пользователи

               Copyright © Системный администратор

Яндекс.Метрика
Tel.: (499) 277-12-41
Fax: (499) 277-12-45
E-mail: sa@samag.ru