Zend Framework, субъективные впечатления

в 12:22, , рубрики: mvc, php, Zend Framework, велосипедостроение, ооп, метки: , , , ,

Недавно мне было поручено разработать некое веб-приложение. Не буду вдаваться в подробности, а лишь скажу, что приложение связанно с планированием перевозок. Есть общедоступная часть, воспользоваться которой может любой посетитель сайта. Есть внутренние интерфейсы для операторов системы. Есть информеры для размещения на сторонних сайтах. С технической точки зрения это несколько десятков экранов, множество различных форм, табличек. Часть экранов используют ajax, кастомные компоненты, написанные на javascript, и всякие красивости типа drag-and-drop. Данные, как обычно, хранятся в реляционной БД в виде полутора десятков таблиц. В общем не совсем примитивное приложение, но и очень сложным назвать его тоже не могу.

По работе мне, мне достаточно часто приходится проектировать или лично кодить подобные приложения. Однако в данном проекте было одно важное требование. Приложение должно быть разработано на базе серьезной и проверенной платформе, а именно на Zend Framework. Использование самописных “велосипедов” — недопустимо. Скажу честно, опыта реальной работы с Zend Framework у меня до сих пор не было. Но платформа известная и за ней стоит известный разработчик. Многими разработчиками Zend Framework вообще рассматривается как стандарт веб разработки. Так что, тем более, есть повод освоить что то новое и солидное. Поэтому я с энтузиазмом взялся за этот проект.

Zend Framework позиционируется как отличная, продуманная и удобная платформа для разработки веб-приложений. Что же такое типичное веб приложения по моему личному мнению? Ну, это много разнообразных экранов, много форм, много табличек. Все это активно работает с базой данных, обычно с реляционной базой данных. Ладно, буду конкретнее, в 95% случаев именно с MySQL. Итак, от платформы для разработки веб приложений я как минимум ожидаю хороших возможностей по созданию форм, разных экранов и удобную работу с реляционной базой данных. И вот я приступил к освоению.

Первым делом мне потребовалось изучить основы самого Zend Framework. Мною была прочитана книжка посвященная этой платформе “Zend Framework. Разработка веб-приложений на PHP. Автор: Викрам Васвани”. Похоже что на данный момент это чуть ли не единственная русскоязычная книжка в печатном виде посвященная подобным фреймворкам. В принципе книжка вполне неплохая. После прочтения у меня появилось общее понимание как работать с фреймворком. Дальше я приступил к изучению официальной документации, чтобы заполнить пробелы и вообще понять, что еще может предоставить мне это фреймворк. Ведь книга рассматривает далеко не все возможности движка. Исчерпывающая и доходчиво написанная официальная документация находится здесь framework.zend.com/manual/ru/ Примерно через неделю я решил что можно приступать к разработке приложения.

Мое мировоззрение на кодинг

Пару слов о моем персональном мнении, как должны писаться продукты. Упомяну здесь и те вещи, за которые меня иногда критикуют.

Я уважаю ООП и применение ООП-патернов. Но применение должно быть обоснованным и реально упрощаться читабельность кода или его дальнейшее совершенствование. Патерны ради патернов — это плохо.

Хороший код — это понятный код. Понятный код — это лаконичный код. Если можно написать код короче, то как правило он будет понятнее. Чем больше логически связанных кусков кода помещается на одном экране монитора — тем лучше.

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

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

if(@$_POST[‘action’] == done) {
   $dbi->exec(“UPDATE bug SET status=’done’ WHERE id=:bugId“, array(‘bugId’=>$bugid));
   $message = “Status changed to <b>Done</b>”
}
elseif(@$_POST[‘action’] == ‘delete’) {
//  ….
}
//…. плюс еще 20-30 других простейших действий...

Да, я здесь смешал Модель, Контроллер, Раутер и Представление. По понятиям MVC, на каждое действие я должен написать отдельный метод в модели, на каждый тип данных сделать свою модель, отдельно сделать скрипты представлений, для управления всем этим использовать Раутер и Контроллер состоящий из многих многих экшен методов. В общем сделать много файлов, много классов и много методов. А я сделал “плохо”, но зато код понятен с первого взгляда, и каждое действие занимает 3 строки кода.
Любому программисту с первого взгляда понятно что будет с БД если выполнится

UPDATE bug SET status=’done’ WHERE id=:bugId

Сразу большое количество логики программы видно в на мониторе без прокрутки. Вся картина видна в целом.

А вот если бы там было написано что то типа того.

function actionDone() {
    $bugId = $this->request->getParam(‘bugId’);

    $bugModel = new BugModel();
    $bugModel->setId($bugId);
    $bugModel->done();
    $bugModel->save();

    $view->setViewScript(“bug_status_changed.phtml”);
    $view->setParam(“status”, “Done”);
}

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

В общем, я против ярого фанатизма, и я за здравый смысл.

Теперь немного о шаблонах в веб-проектах. Я не понимаю когда используются шаблонные языки типа Smarty. Ну зачем выдумывать новый и в общем то ограниченный язык, когда есть полноценный язык PHP? Неужели верстальщика тяжело научить использовать PHP-шные if(...) и foreach(...)? Это не сложнее чем в Smarty, но зато мы не добавляем в проект новый экзотический язык.
Кстати, бывает что шаблоны (или Представления) настолько сложные, что в них приходится использовать не только if и foreach, а и весьма сложные конструкции, включая классы и объекты которые наследуются друг от друга. Хотя при этом, задача преставления остается прежней — сформировать HTML кода по сухим данным.

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

function renderTemplate($file, $vars) {
    foreach($vars as $_name => $_var) {
        $$_name = $_var;        
    }
    require $file;
}

// Пример 
renderTemplate(“page.tpl.php”, array(
   ‘menu’ => array(‘/about’=>’О нас’, ‘/contacts’=>’Контакты’)
   ‘title’ => ‘Заголовок’
));

Плюс конечно простейшая обвязка для экранирования, для кэширования. Вроде больше ничего и не надо для счастья.

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

Установка Zend Framework

Фрейморк, как оказалось весит около 25МБ и содержит почти 3000 файлов. Неслабо, подумал я! Видимо там реализовано все, о чем только можно мечтать!

Для начала меня немного расстроила структура директорий приложения на базе Zend Framework. Она предполагает весьма большое количество директорий. При работе над проектом я постоянно терялся в этой структуре. Также немного перемудерными мне показались правила названия создаваемых классов, правил для именования файлов и путей их размещения. Логика в них есть, но честно говоря, можно было все это сделать более интуитивно понятным. И особо сильно утомляет путешествия по директориям скриптов представления. Хотя, видимо если долго работать с этим фреймворком, то привыкнешь.

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

Оказалось, что Zend Framework не рассчитан на работу с shared хостингом, т.к. для запуска требует прописать в VirtualHost директорию отличную от корня вашего проекта. Так что у вас должен быть как минимум VDS сервер. Ну да, я слышал мнение, что если вы разрабатываете что то, что работает на shared хостинге, то вы не крут, и вам не место среди профессионалов. Хотя я всегда считал что 95% все веб проектов крутятся именно на shared хостингах. Ладно, в любом случае после нескольких взмахов напильника, я все же заставил проект подняться на shard хотинге.
Работа с базами данных

Сначала мне потребовалось создать модели данных (в рамках концепции MVC). Как выяснилось, Zend Framework не предоставляет ничего готового для создания моделей. По сути надо просто написать собственный класс, методы которого будут выполнять ту или иную операцию по работе с БД. Т.е. в ряде случаев, класс модели, это всего лишь обертка над SQL запросом, а иногда SQL запросы с некой дополнительной обвязкой на PHP.

Для работы с БД во фреймворке используется компоненты Zend_Db. Он позволяет работать с большинством распространенных СУБД через SQL.

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

Итак, как выглядит работа с SQL в Zend Framework? Ну вот примерно так:
$result = $db->fetchAssoc('SELECT * FROM items WHERE age>18');

И далее с $result можно работать как с ассоциативным массив в виде таблицы (хоть это и не совсем массив), что весьма удобно. Можно передать массив на дальнейшую обработку, а можно и напрямую передать его в представление для вывода на экран.

Есть еще несколько удобных методов, которые извлекают результаты SQL запросов:
fetchCol() — извлекает одну колонку. Удобно когда надо получить список ID-шек
fetchRow() — извлекает первую строку. Удобно когда надо выбрать одну запись по первичному ключу.
fetchOne() — Извлекает только одно значение. Тоже удобно, когда реально надо получить одно значение и не хочется писать код для выковыривания одного элемента его из массива.
fetchPairs() — извлекает пары значений в виде ассоциативного массива. Создает хэш типа ключ-значение. Очень полезно создания разных словариков.

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

Метод fetchPairs() это хорошо. А теперь, как на получить чуть более интересный и нужный словарь, где в качестве ключа идет первичный ключ БД, а в качестве значения массив-запись БД? Очень распространенная задача. Ответ — а никак! Делай руками, вызывай fetchAssoc(), потом крути цикл и генерируй новый массив-словарь. Обидно!

Ладно, идем дальше. Надо извлечь из таблицы данные и построить из них древовидный массив. Оказывается эта распространенная операция тоже никак не реализована. Т.е. нет ничего типа:

$hierarhy = db->fetch_tree(“SELECT id, parent_id, title FROM tree‘, ‘id’, ‘parent_id’);

Идем дальше. В SQL надо передавать параметры. Ну да, на первый взгляд все просто. Делается это так:

$result = $db->fetchAssoc('SELECT * FROM news WHERE id = ?', 7);

Можно даже несколько плейсходлеров поставить.

$result = $db->fetchAssoc('SELECT * FROM news WHERE chapter=? AND type=?', array(2, 8));

Только вот когда программа становиться сложной, то и сложность запросов растет, и параметров могут быть уже десятки. Нужны именованные плейсхолдеры. Считать плейсхолдеры по их порядковому номеру — далеко не располагает к понятности кода. Как выяснилось, Zend Framework их не реализует! Они работает, но только если выбранная БД их поддерживает. Например если вы выбрали MySQLi то с именованными плейсходерами вы работать не сможете. Неужели было так сложно было сделать эмуляцию именованных параметров?! Ладно, эта вещь оказалось решаемой. Оказывается если в качестве адаптера выбрать Pdo_Mysql вместо Mysqli, то именованные параметры начнут работать, т.к. они эмулируются внутри самого PDO. Признаться, я дошел до этого не сразу. Обида стихла, но неприятный осадок остался.

Частенько в SQL запросах приходится использовать код типа такого

SELECT * FROM items WHERE id IN(1,3,5,8,12)

где список id-шек, динамически генерируется из массива.
Я лично не нашел способа сделать это на Zend Framework красиво. Есть некий метод quoteInto(). Использовать можно так

$sql = $db->quoteInto(«SELECT * FROM item WHERE id=IN(?)», array(1,3,5,8,12)));

Смиряемся с невозможностью использовать именованные параметры в запросе, с составлением сложного запроса из нескольких кусков, но зато получаем нужный SQL код. Только мы еще получим синтаксичекую ошибку, если массив окажется пустым, потому что SQL получиться такм SELECT * FROM item WHERE id=IN().
Так что или заворачиваем все в if(count($idList) > 0), или к массиву каждый раз добавляем заведомо недопустимый идентификатор, например -1. Кривенько, но решение. Но все равно, получается? что составить сложный SQL запрос можно только путем конкатенации строк. А я предпочитаю видеть любой SQL запрос в виде одного куска кода, без какого либо вкрапления PHP. А то блин, смешивать HTML и PHP считается плохо, а вот SQL и PHP, видете ли, нормально.

В общем, не сильно мне порнавился Zend_Db. Какой то большой выгоды или сильного удобства от его использования я не заметил. Кстати Zend_Db весит больше 500КБ.

Вообще, для работы с БД я предпочитаю использовать самодельный маленький класс со следующими публичными методами:

connect($config) — Соединяется с БД
fetchTable($sql, $params) — Возвращает массив записей
fetchRow($sql, $params) — Возвращает одну запись
fetchCol($sql, $params) — Возвращает одну колонку
fetchOne($sql, $params) — Возвращает скаларное значение
fetchPairs($sql, $key, $value, $params) — Возвращает массив ключ — значение
fetchDict($sql, $key, $params) — Возвращает массив ключ — запись
fetchTree($sql, $key, $parent, $params) — Возвращает дерево
exec($sql, $params) — Выполняет не select запрос
getInsertId() — Возвращает последний сгенерированный автоинкремент
getAffectedRows() — Возвращает число измененных записей

Результаты выполнения — обычные ассоциативные массивы логичной для каждого из случаев структуры. Параметры — всегда именованные, можно передавать числа, строки, а также списки. Класс кидается исключениями, если в SQL допущена синтаксическая ошибка. Этот “велосипед” весит в районе 10КБ, и честно говоря мне его всегда хватает. Другим программистам на его изучение требуется 10 минут, а для изучения Zend_Db — часы внимательного штудирования мануалов, и все равно остаются вопросы. Путем расширения класса и переопределения нескольких виртуальных методов, можно добавить поддержку любой другой БД.

Формы

Следующая важнейшая вещь для веб-приложения это формы. Обычно их весьма много. Структура и внешний вид тоже очень разнообразны. Для работы с формами в Zend Framework используется Zend_Form.

Чтобы создать форму надо сконструировать объект формы. В него надо добавить объекты каждого из полей. В полях можно настроить разные параметры. Указать валидаторы и т.п. Вот пример создания поля

$username = new Zend_Form_Element_Text('username');
$username
         ->addValidator('alnum')
         ->addValidator('regex', false, array('/^[a-z]+/'))
         ->addValidator('stringLength', false, array(6, 20))
         ->setRequired(true)
         ->addFilter('StringToLower');

Создав все поля — добавляем их к объекту формы

$form
      ->setAction('/somepath')
      ->setMethod('post')       
      ->addElement($name)
      ->addElement($company)
      ->addElement($email)
      ->addElement($phone)
      ->addElement($action);

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

$form->addElement(new Zend_Form_Element_Text('username'))
    ->setRequired(true)
    ->addValidator('regex', false, array('/^[a-z]+/'));

Но так нельзя!

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

Попробуем указать кривой емейл “xxx”. Получаем сообщение о некорректности:

'xxx' is no valid email address in the basic format local-part@hostname

Нормально. Разумеется надо бы перевести сообщение на русский, но там это можно легко сделать. А теперь давайте попробуем еще более кривой адрес “я@_domain.xx”.
И вот что мы получаем:

'_domain.xx' is no valid hostname for email address 'я@_domain.xx'
'_domain.xx' appears to be a DNS hostname but cannot match TLD against known list
'_domain.xx' does not appear to be a valid local network name
'я' can not be matched against dot-atom format
'я' can not be matched against quoted-string format
'я' is no valid local part for email address 'я@_domain.xx'

6 сообщений! Кому это надо? Неужели рядовой пользователь вообще поймет что такое TLD, quoted-string или dot-atom? Рядовому пользователю нужно только одно единственное сообщение — “Некорректный E-mail” и все! Но стандартные средства этого не позволяют. Чтобы избавиться от вывода этой кучи сообщений — предлагается написать свой собственный класс валидатора.

Ну допустим сделали мы форму, настроили все поля, все валидаторы. Теперь выведем ее. По умолчанию HTML код форма будет выглядеть примерно так:

<dl class="zend_form">
    <dt id="phone-label">
        <label for="phone" class="required">Телефон</label>
    </dt>
    <dd id="phone-element">
        <input type="text" name="phone" id="phone" value="">
        <ul class="errors"><li>Введите значение</li></ul>
    </dd>
    ...
</dl>

Причем в DD элементы буду заворачиваться даже hidden поля! А потом мы начнем удивляться, что за странные пустоты образовались в форме. В общем интеллекта для особой обработки hidden полей у фреймворка нет.

А что если меня простейший DL список не устраивает? Вдруг мне надо сделать форму со сложной структурой? Тут предлагается использовать классы декораторы. Декораторы, это такие классы которые позволяют заворачивать элементы формы в определенные HTML теги, например можно завернуть элемент не в тег DT, а например в DIV. О ужас! “Правильный” MVC фреймворк вынуждает нас заниматься версткой прямо внутри контроллера, причем самым нечитабельным способом!

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

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

Конфигурирование формы:

$form = new UniversalForm(array(        		
    'name'  => array('label'=>'Имя', 'required'=>true),
    'email'  => array('label'=>'Имя', 'type'=>’email’),
    'birthday' => array('label'=>'Дата рождения', 'type'=>'date'),
    'sex'      => array('label'=>'Пол', 'type'=>'choice', 'items'=>array('m'=>'М', 'f'=>'Ж')),
    'code' => array('label'=>'Код', ‘regExp’=>’/^d{6}$/’, ‘regExpMessage’=>’Неверный код’, ‘value’=>’000000’),
    'action'  => array('type'=>'hidden', 'value'=>'updateInfo'),                
)); 

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

$form->setFromPost(); // Установить данные из POSTа
$form->isValid(); // Проверить что данные валидны. Если нет, то будут установлены сообщения об ошибках, которые выведет скрипт представления формы.
$data = $form->getValues(); // Получить значения
$form->setValues($data); // Установить значения полей. Хотя можно это же сделать через конфиругирующий массив в конструкторе
$form->render(‘form.tpl.php’); // Сформировать HTML. Если не указан шаблон, то будет использован шаблон по умолчанию.
$form->renderJs(); // Сформировать JavaScript валидаторы, которые дублируют PHP валидаторы на стороне клиента.

Ну а теперь несколько методов, неправильных с точки зрения MVC.
$form->getSqlSet(); // Вернет кусок SQL кода
Можно использовать так

$sql = “UPDATE account SET “.$form->getSqlSet().” WHERE id=777”;

Следующие методы используются для тех же целей.
$form->getSqlFieldsList();
$form->getSqlValuesList();
Да, это не совсем канонически правильное решение. Но зато набор полей надо описать только один раз в конструкторе формы. В случае его изменения автоматически изменятся и все SQL запросы.

Админки

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

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

Выводы и впечатления

Как вы поняли, этот “правильный” фреймворк произвел на меня не очень хорошее впечатление.

Но пожалуй что Zend Framwork все же можно порекомендовать к изучению в следующих случаях:
1. Если у вас недостаточно опыта чтобы сделать сносный каркас для своего приложения.
2. Если вы вообще не умеете работать с MVC, но хотите прочувствовать эту идеологию
3. Если у вас нет хорошего представления об ООП и применении типичных патернов проектирования, то Zend Framwork покажет вам множество примеров. Там они использовали ООП везде где надо и не надо.
4. Так же Zend Framwork вполне сойдет, если вы делаете несложное приложение, работа которого больше сводится к извлечению данных из БД и выводу на экран. Например какие нибудь новостные порталы или каталоги продукции.

А лично я наткнулся на неудобства практически во всех компонентах фреймворка с которыми пытался работать. Регулярно ворчал себе под нос фразы типа “Это же можно было сделать удобнее!” или “Ну почему этого они не реализовали!”. У меня создалось впечатление, что главная задача разработчиков Zend Framwork была сделать все ООПно. Все что можно, все завернуть в враперы, адаптеры, и не важно, будет ли это удобно или нет. Уровень абстракции в библиотеке крайне высок, настолько высок, что в нем тяжело разбираться. И главное, практически любой компонент надо допиливать под свои потребности. В сыром виде все работает не так как хочется.

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

А еще мне вспомнился советский анекдот, когда собирая по чертежам самолет, получался паровоз. А в чертежах было написано — “получившееся изделие — доработайте напильником”. Я не хочу дорабатывать паровоз до самолета напильником. Я хочу чтобы все сносно работало прямо “из коробки”.

P.S.: Заранее прошу прощения, если я задел чьи то “религиозные чувства”.

Автор: tushev


  1. Pycu4:

    Спасибо за статью!!! Осознал, что все равно пилить движок нужно самостоятельно. Лично для меня это сложнее :c

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js