Ну наконец-то руки дошли что-то запостить :) (варнинг! много букаф!)

Предыстория

Как правило, внутренности любого веб-проекта на боевом сервере и на машине разработчика отличаются, и порой довольно заметно. Например, при разработке я использую несколько javascript-файлов, каждый из которых решает определённый круг задач, верстальщикам удобнее иметь несколько стилевых файлов (один для структуры, другой для кнопочек, третий ещё для чего-нибудь и т.д.). Это нормально — так удобнее работать. А после размещения этих файлов на сервере, например, для улучшения производительности, их желательно слить в один, да ещё и упаковать каким-нибудь компрессором. Соответственно, каждое изменение порождает довольно много лишних действий: слияние, сжатие, установка версий (для принудительного обновления в случае жёсткого кэширования), возможны и другие рутинные операции. Рано или поздно, каждый разработчик задумывается об оптимизации таких процессов. В результате пишутся скрипты, вспомогательные утилиты и другие локальные “полезности”. Некоторое время я тоже занимался подобной деятельностью, но в итоге понял, что это не совсем удобно, а точнее не есть самый оптимальный подход к решению проблемы, т.к. во-первых, отсутствует единый стандарт оформления таких процедур, а во-вторых — могут возникнуть сложности при передаче проекта другим разработчикам. В итоге выяснилось, что мне необходима некая универсальная и хорошо конфигурируемая утилита для “сборки” проектов перед выкладыванием их на рабочие сервера. После непродолжительных поисков она была найдена — знакомьтесь, Apache Ant! Сей проект есть не что иное, как ещё одна утилита сборки (как например, make — широко распространённая в *nix-системах) программ из исходных текстов. Что же в нём такого хорошего? Ant бесплатен, работает кроссплатформенно (единственное требование — наличие виртуальной машины Java), имеет понятный (в сравнении с make) язык конфигурационных файлов (я буду их называть build-файлами), основанный на XML-разметке. Стоит сказать, что изначально этот проект был предназначен для сборки Java-приложений и имеет для этого свои специфические функции, но он также очень хорошо работает и с любыми другими текстовыми данными, поэтому нам это не страшно. Итак, сборщик выбран, приступаем к изучению :)

Установка

Для работы нам понадобится: JRE, JDK и собственно, сам Ant. Виртуальную машину Java и Development Kit можно найти на официальном сайте разработчиков, последний дистрибутив сборщика — на сайте проекта. Установка Java не представляет сложностей, т.к. для Windows она поставляется в виде инсталляторов. Дистрибутив Ant представляет собой обычный архив, его нужно распаковать в удобное вам место. Далее нужно выполнить несколько несложных действий, чтобы помочь сборщику ориентироваться в нашей системе:
  • Установить переменную окружения CLASSPATH в .
  • Установить переменную окружения JAVA_HOME в значение пути к установленному JDK (например у меня это C:\Program Files\Java\jdk1.6.0_03)
  • Установить переменную окружения ANT_HOME в значение пути к каталогу, куда вы распаковали Ant (у меня: E:\ant)
  • Добавить исполняемый каталог Ant’а (E:\ant\bin) к переменной окружения PATH
Уже можно работать :) для этого можно запустить интерпретатор командной строки и набрать команду ant или ant -version. В результате вы должны получить примерно следующее:
Buildfile: build.xml does not exist!
Build failed
или
Apache Ant version 1.7.1 compiled on June 27 2008

Использование

Я покажу, как использовать этот сборщик на примере некоего абстрактного проекта. Вот его структура:

/
  /css
  /css/other.css
  /css/structure.css
  /css/ui.css
  /engine
  /scripts
  /scripts/events.js
  /scripts/framework.js
  /scripts/transmission.js
  config.php
Здесь приведены только интересующие нас файлы, т.к. остальное при установке на рабочий сервер останется в неизменном виде. Сначала составим последовательность действий:
  • Слияние css-файлов
  • Слияние js-файлов
  • Упаковка результирующих css- и js-файлов компрессором (опционально)
  • Установка некоего флага в конфигурационном файле, по которому контроллер должен понять, что загружать следует один сжатый файл, вместо нескольких отдельных
  • Установка версии сборки (для сброса локального кэша браузеров)
Build-файл Ant (или сценарий сборки) может называться как угодно и располагаться в любом удобном месте. В нашем проекте мы назовём его build.xml (стандартное имя Ant, которое будет искаться в первую очередь, если не указано другое) и расположим его в корневом каталоге нашего проекта (пусть он входит в проект — так мы сможем использовать контроль версий для этого файла). После этих действий, для сборки проекта потребуется всего лишь ввести команду ant в командной строке, находясь в корневом каталоге проекта. Если же по каким-либо причинам вы хотите разместить ваш сценарий сборки в другом месте, то команда запуска будет такой: ant -f путь_к_файлу. Как уже было сказано выше, сборочный сценарий представляет собой XML-файл. Корневым его элементом является узел project, который имеет атрибуты name и default. Первый задаёт имя проекта, а второй — цель, выполняемую по умолчанию. А что такое цель (я буду придерживаться дословного перевода термина target)? Итак, цель — это определённый набор задач, который выполняет свою, обособленную функцию в процессе сборки проекта. Цель определяется узлом target и должна быть определена строго в корневом узле сборочного сценария. Атрибуты: name (идентификатор цели), description (описание). Следует заметить, что здесь я не буду описывать все возможные атрибуты конфигурирования сборочного сценария. А для любознательных всегда есть мануал :) В общем, теперь мы кое-что знаем, а поэтому давайте попробуем сделать что-нибудь вживую:

<project name="Sample" default="build">
  <target name="build">
    <echo>Hello, World!</echo>
  </target>
</project>
Это и есть простейший сборочный сценарий Ant. Если его запустить, то в консоли выведется всем уже порядком поднадоевшая надпись Hello, World! (а что делать, все начинается с неё :) ). Здесь появился новый узел — echo. В классификации Ant это задача (Task). Задачи позволяют сборщику выполнять определённые действия, например копирование, замена, упаковка в архив и т.п. Ant имеет набор основных задач (Core Tasks), которые доступны всегда, но другая его особенность состоит в возможности подключения дополнительных задач с любым необходимым функционалом. Эти дополнительные “модули” представляют собой Java-классы, подробно на них мы останавливаться не будем, а если вы хотите покопаться в этом направлении, то вам на официальный сайт проекта. Итак, задача echo предназначена для вывода чего-либо в консоль интерпретатора командной строки. Логично, что выводимый текст нужно располагать вложенным узлом для этой задачи (только не забывайте про правила XML-разметки и спецсимволы!) Кроме того, теперь понятно как работает атрибут default узла project — ведь наша цель имеет такой же идентификатор name, как и его значение (build). Теперь можно определиться с дальнейшей структурой скрипта. Сделаем цель build основной, а остальные цели будем вызывать из неё. Вызов другой цели может осуществляться несколькими способами:
  • задачей antcall
  • при помощи атрибута depends узла target
Мы остановимся на первом варианте и заодно опишем нужные нам цели:

<project name="Sample" default="build">

  <target name="build">

    <buildnumber />
    <echo>Build number: ${build.number}</echo>

    <antcall target="prepare" />

    <antcall target="compress" />

    <antcall target="modify" />

    <antcall target="finalize" />

  </target>

  <target name="prepare" description="">

  </target>

  <target name="compress" description="">

  </target>

  <target name="modify" description="">

  </target>

  <target name="finalize" description="">

  </target>

</project>
Мы выделили четыре вспомогательных цели: prepare (подготовка), compress (сжатие), modify (изменение), finalize (завершение). Вызов их будет происходить в порядке, указанном последовательностью вызова задачи antcall, имя цели в ней указывается атрибутом target. А сейчас, давайте посмотрим на две новых строчки в описании цели build: это задача buildnumber и странное содержимое задачи echo. Задача buildnumber предназначена для получения текущего идентификатора сборки. Он представляет собой десятичное число, которое при каждом новом запуске этой задачи увеличивается на единицу. Для того, чтобы Ant мог хранить значение этого идентификатора, он записывает его в файл опций по тому же пути, что и скрипт сборки. Файл опций имеет название build.number, если при запуске сценария он не был найден, то Ant создаст его и первой версией будет 0. Кроме того, эта задача записывает текущее значение идентификатора в переменную build.number. Ну а теперь пришло время поговорить о переменных в сценариях Ant. На самом деле, это не переменные, а скорее константы, т.к. изменить их после определения невозможно. Переменные могут создаваться различными задачами (в этом случае их имена можно найти в мануале), либо вы можете создавать их самостоятельно, для удобства построения сценария. Существуют также несколько предустановленных переменных, отражающих окружение среды, в которой выполняется Ant. Создаются переменные при помощи задачи property, которая имеет атрибуты name (имя переменной) и value (значение). Например:

<property name="some_variable" value="It's me!" />
Для того, чтобы получить содержимое переменной, её имя записывается в фигурных скобках с префиксом $:

<echo>${some_variable}</echo> <!-- выведет It's me! -->
Довольно просто, не так ли? Только не забывайте, что переменная должна быть определена до момента первого использования (да, это вам не пэхапе :) ). Переменные можно использовать как в содержимом узлов, так и в атрибутах. Давайте для удобства добавим несколько переменных в наш сценарий:

<property name="build.dir" value="${basedir}/ant-build/" />
<property name="css.dir" value="${build.dir}css/" />
<property name="js.dir" value="${build.dir}scripts/" />
<property name="output.file" value="joined" />
<property name="config.file" value="${build.dir}/config.php" />
<property name="compressor.path" value="e:\yuic\yuicompressor.jar" />
Здесь мы создали переменные, представляющие собой определенные пути в файловой системе. Это нам нужно для удобной работы с исходными файлами, которые подлежат обработке. Обратите внимание на переменную basedir, которая используется в значении переменной build.dir — это предопределённое значение, содержащее путь к рабочему каталогу текущего скрипта сборки (корневой путь нашего проекта). Описания созданных переменных:
  • build-dir: каталог, в котором будет находиться скомпилированный проект
  • css.dir: подкаталог со стилевыми файлами
  • js.dir: каталог скриптов
  • output.file: имя результирующего файла для сжатых стилей и скриптов
  • config.file: путь к файлу конфигурации, в который мы будет записывать номер сборки и флаг
  • compressor.path: путь к исполняемому файлу компрессора (в данном случае это YUICompressor)
Теперь реализуем функционал первой, подготовительной цели (prepare):

<target name="prepare" description="">

  <delete dir="${build.dir}" />

  <mkdir dir="${build.dir}" />

  <copy todir="${build.dir}">
    <fileset dir="${basedir}">
      <exclude name="*ant-build*" />
      <exclude name="*.svn*" />
      <exclude name="*_svn*" />
      <exclude name="build.number" />
      <exclude name="build.xml" />
    </fileset>
  </copy>

</target>
Здесь используются задачи delete (удаление сборочной директории, на всякий случай), mkdir (создание её заново) и copy (копирование в эту директорию всего проекта, за исключением некоторых файлов). С первыми двумя всё понятно, а вот в copy появляется новый элемент: fileset. Внутри узла copy мы должны указать, что нужно копировать. Для этого мы используем встроенный тип данных fileset (набор файлов). В данном случае, мы используем все содержимое корневой директории проекта за исключением (exclude) сборочного каталога, путей, содержащих .svn или _svn (это метаданные системы контроля версий, если вы ей пользуетесь), самого сценария сборки (build.xml) и файла параметров (build.number). Все эти файлы в итоговом проекте нам не нужны, поэтому мы их и отсеиваем. Теперь приступим к процессу слияния файлов. Для этого служит задача concat. Она принимает путь к результирующему файлу в атрибуте destfile и список исходных файлов в значении узла. Кроме того, мы можем явно указать кодировку исходных и результирующего файлов:

<concat destfile="${css.dir}${output.file}.css" encoding="UTF-8" outputencoding="UTF-8">
  <fileset file="${css.dir}other.css" />
  <fileset file="${css.dir}structure.css" />
  <fileset file="${css.dir}ui.css" />
</concat>
Такая задача сольёт указанные три файла в один, под именем joined.css. Но приведенный подход не очень удобен: чуть позже, нам нужно будет удалить исходные файлы (на продакшене они не используются), а для этого придётся повторять использование наборов fileset. Конечно, в случае пары файлов это не страшно, но если их побольше, то гораздо удобнее заранее определить их списки. Это можно сделать при помощи типа данных filelist:

<filelist id="css.files" dir="${css.dir}">
  <file name="other.css" />
  <file name="structure.css" />
  <file name="ui.css" />
</filelist>
Здесь мы определяем рабочий каталог и набор файлов, которые будут использоваться. Стоит заметить, что и в filelist и в fileset существует несколько способов определить набор файлов, не обязательно указывая каждый при помощи узла file. Можно, например, воспользоваться специальными командами поиска по маске. Но вернёмся к коду. Данному набору мы присвоили идентификатор id — это нужно, чтобы в дальнейшем обращаться к нему. Запомните, это не переменная! И используется этот идентификатор только применительно к типу данных fileset. Теперь мы можем использовать этот набор следующим образом:

<concat destfile="${css.dir}${output.file}.css" encoding="UTF-8" outputencoding="UTF-8">
  <filelist refid="css.files" />
</concat>
Атрибут refid указывает как раз на нужный нам набор. В дальнейшем, мы можем пользоваться им и для других действий. Итак, первая цель завершена. Вот её полный код:

<target name="prepare" description="">

  <delete dir="${build.dir}" />

  <mkdir dir="${build.dir}" />

  <copy todir="${build.dir}">
    <fileset dir="${basedir}">
      <exclude name="*ant-build*" />
      <exclude name="*.svn*" />
      <exclude name="*_svn*" />
      <exclude name="build.number" />
      <exclude name="build.xml" />
    </fileset>
  </copy>

  <concat destfile="${css.dir}${output.file}.css" encoding="UTF-8" outputencoding="UTF-8">
    <filelist refid="css.files" />
  </concat>

  <concat destfile="${js.dir}${output.file}.js" encoding="UTF-8" outputencoding="UTF-8">
    <filelist refid="js.files" />
  </concat>

</target>
Можно приступать к дальнейшей обработке. На очереди у нас сжатие полученных файлов. Заниматься этим будет внешняя утилита YUICompressor, а следовательно, нам понадобится возможность запускать другие программы. Эта возможность у Ant’а есть и реализована задачей exec, которой в атрибуте executable передается имя исполняемого файла, а вложенными узлами arg — дополнительные параметры (те, которые в командной строке разделены пробелами), пример:

<exec executable="format">
  <arg value="/?" />
</exec>
вызовет системную команду format с параметром /? Попробуем составить задачу для запуска нашего упаковщика. Очевидно, что запускаться он будет два раза (для css- и для js-файлов), а оформлять для этого две задачи, отличающиеся только параметрами не очень правильно. Поэтому мы будем запускать цель compress два раза, но с разными параметрами. Параметры можно передать при вызове задачи antcall при помощи узла param. Формат их такой же, как и у переменных, да по сути они и являются переменными, доступными внутри указанной цели. Вот как это выглядит:

<antcall target="some_target">
  <param name="some_param" value="some_value" />
</antcall>
Запишем содержимое цели compress с использованием параметров:

<target name="compress" description="">

  <echo>Compress file ${param.input} to ${param.output}</echo>

  <exec executable="java">
    <arg value="-jar" />
    <arg value="${compressor.path}" />
    <arg value="--type" />
    <arg value="${param.type}" />
    <arg value="--charset" />
    <arg value="${param.encoding}" />
    <arg value="-o" />
    <arg value="${param.input}" />
    <arg value="-v" />
    <arg value="${param.output}" />
  </exec>

</target>
compressor.path является глобальной переменной, остальные — должны быть переданы при вызове цели. Внутри цели build заменим одиночный вызов на два, с различными параметрами:

<antcall target="compress">
  <param name="param.type" value="css" />
  <param name="param.encoding" value="utf-8" />
  <param name="param.input" value="${css.dir}${output.file}.css" />
  <param name="param.output" value="${css.dir}${output.file}.css" />
</antcall>

<antcall target="compress">
  <param name="param.type" value="js" />
  <param name="param.encoding" value="utf-8" />
  <param name="param.input" value="${js.dir}${output.file}.js" />
  <param name="param.output" value="${js.dir}${output.file}.js" />
</antcall>
Скрипты и стили слиты и упакованы, осталось подправить наш конфигурационный файл. Допустим, флаг и номер сборки там хранятся в двух константах, примерно так:

define('SAMPLE_FLAG', false);
define('SAMPLE_VERSION', '@version');
@version является вымышленной метакомандой, на её место нам нужно поставить идентификатор текущей сборки. В этом простом случае можно воспользоваться задачей replace с тремя атрибутами: file (собственно, файл), token (искомая подстрока) и value (значение, на которое эту подстроку нужно заменить). Получаем:

<replace file="${config.file}" token="@version" value="${build.number}" />
Всё в порядке. Теперь посложнее: нам нужно изменить значение флага SAMPLE_FLAG с false на true, но использовать replace здесь не получится — там ведь не одна false может быть, правда? Поэтому воспользуемся регулярными выражениями при помощи задачи replaceregexp:

<replaceregexp file="${config.file}" flags="gi" byline="true" encoding="utf-8">
  <regexp pattern="(sample_flag.?,\s+)false" />
  <substitution expression="\1true" />
</replaceregexp>
Тут тоже нет ничего сложного, файл, модификаторы выражения (gi), кодировка. Атрибут byline, если он установлен в true, указывает Ant’у, что файл нужно обрабатывать построчно, а не как один текстовый кусок. Вложенный узел regexp задаёт само выражение, substitution — шаблон замены. На этом, цель modify можно считать законченной.

<target name="modify" description="">

  <echo>Modifying config file</echo>

  <replace file="${config.file}" token="@version" value="${build.number}" />

  <replaceregexp file="${config.file}" flags="gi" byline="true" encoding="utf-8">
    <regexp pattern="(sample_flag.?,\s+)false" />
    <substitution expression="\1true" />
  </replaceregexp>

</target>
Вот вы и подошли к завершающей стадии сборки. Она не сложная, поэтому я сразу приведу пример:

<target name="finalize" description="">

  <delete verbose="true">
    <filelist refid="css.files" />
  </delete>

  <delete verbose="true">
    <filelist refid="js.files" />
  </delete>

  <zip destfile="${basedir}/build-${build.number}.zip" basedir="${build.dir}" />

  <delete dir="${build.dir}" />

</target>
Тут уже все понятно: мы удаляем исходные css- и js-файлы, затем упаковываем готовую сборку в zip-архив и удаляем временный каталог. Всё, можно радоваться :) Вот то, что у нас в итоге вышло:

<project name="Sample" default="build">

  <property name="build.dir" value="${basedir}/ant-build/" />
  <property name="css.dir" value="${build.dir}css/" />
  <property name="js.dir" value="${build.dir}scripts/" />
  <property name="output.file" value="joined" />
  <property name="config.file" value="${build.dir}/config.php" />
  <property name="compressor.path" value="e:\yuic\yuicompressor.jar" />

  <filelist id="css.files" dir="${css.dir}">
    <file name="other.css" />
    <file name="structure.css" />
    <file name="ui.css" />
  </filelist>

  <filelist id="js.files" dir="${js.dir}">
    <file name="events.js" />
    <file name="framework.js" />
    <file name="transmission.js" />
  </filelist>

  <target name="build">

    <buildnumber />
    <echo>Build number: ${build.number}</echo>

    <antcall target="prepare" />

    <antcall target="compress">
      <param name="param.type" value="css" />
      <param name="param.encoding" value="utf-8" />
      <param name="param.input" value="${css.dir}${output.file}.css" />
      <param name="param.output" value="${css.dir}${output.file}.css" />
    </antcall>

    <antcall target="compress">
      <param name="param.type" value="js" />
      <param name="param.encoding" value="utf-8" />
      <param name="param.input" value="${js.dir}${output.file}.js" />
      <param name="param.output" value="${js.dir}${output.file}.js" />
    </antcall>

    <antcall target="modify" />

    <antcall target="finalize" />

  </target>

  <target name="prepare" description="">

    <delete dir="${build.dir}" />

    <mkdir dir="${build.dir}" />

    <copy todir="${build.dir}">
      <fileset dir="${basedir}">
        <exclude name="*ant-build*" />
        <exclude name="*.svn*" />
        <exclude name="*_svn*" />
        <exclude name="build.number" />
        <exclude name="build.xml" />
      </fileset>
    </copy>

    <concat destfile="${css.dir}${output.file}.css" encoding="UTF-8" outputencoding="UTF-8">
      <filelist refid="css.files" />
    </concat>

    <concat destfile="${js.dir}${output.file}.js" encoding="UTF-8" outputencoding="UTF-8">
      <filelist refid="js.files" />
    </concat>

  </target>

  <target name="compress" description="">

    <echo>Compress file ${param.input} to ${param.output}</echo>

    <exec executable="java">
      <arg value="-jar" />
      <arg value="${compressor.path}" />
      <arg value="--type" />
      <arg value="${param.type}" />
      <arg value="--charset" />
      <arg value="${param.encoding}" />
      <arg value="-o" />
      <arg value="${param.input}" />
      <arg value="-v" />
      <arg value="${param.output}" />
    </exec>

  </target>

  <target name="modify" description="">

    <echo>Modifying config file</echo>

    <replace file="${config.file}" token="@version" value="${build.number}" />

    <replaceregexp file="${config.file}" flags="gi" byline="true" encoding="utf-8">
      <regexp pattern="(sample_flag.?,\s+)false" />
      <substitution expression="\1true" />
    </replaceregexp>

  </target>

  <target name="finalize" description="">

    <delete verbose="true">
      <filelist refid="css.files" />
    </delete>

    <delete verbose="true">
      <filelist refid="js.files" />
    </delete>

    <zip destfile="${basedir}/build-${build.number}.zip" basedir="${build.dir}" />

    <delete dir="${build.dir}" />

  </target>

</project>

Заключение

Хочу сказать, что сам я пользуюсь этим сборщиком довольно недавно, но с самого первого запуска он оставляет о себе только приятные впечатления :) Конечно, в этой статье приведена лишь малая часть того, что он умеет. Многое осталось “за кадром” (ну например, вызов отдельных целей из консоли, интеграция с системами контроля версий, файлы параметров, связанные сценарии сборки). За более полной информацией о самом сборщике и подробными описаниями всех возможных задач можно обратиться к руководству. Спасибо за внимание!

Метки: , , ,

Один комментарий “Apache Ant — подготавливаем проект к публикации”

  1. Очень интересно, но все в будущем хотелось бы еще побольше узнать об этом. Очень понравилась ваша статья!