понедельник, 17 февраля 2014 г.

Включение в разрабатываемое ПО номера версии

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

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

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

Когда программист пишет программу, работающую в операционной системе Windows, для решения задачи включения информации о версии он, скорее всего, воспользуется структурами и механизмами, которые эта ОС ему предоставляет. Так делают практически все. Даже если не осознают этого - многие IDE позволяют работать с этими структурами не задумываясь и даже не зная о них. Делается это для того, чтобы облегчить последующее сопровождение ПО, ну и, попутно, помочь программистам пройти путь достижения очень важной цели - написания качественного ПО.

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

Как создать и включить этот ресурс в программу - решать программисту. Можно создать этот файл вручную - в соответствии со спецификациями Microsoft и с помощью мощного текстового редактора Notepad, затем скомпилировать его и прилинковать к программе. А можно, как я уже упоминал, воспользоваться средствами, предоставляемыми широко распространенными IDE, тем же Delphi, или Visual Studio.

Если вы каким-либо образом добавили ресурс VERSIONINFO к своему ПО, то вы можете использовать информацию из него, обращаясь к стандартным функциям WinAPI и манипулируя стандартными же структурами данных - вот вам еще один плюс использования механизма ОС. Более того, даже другие разработчики и, следовательно, их программы, смогут добывать нужную им информацию из вашего ПО.

Механизмы версионирования программного обеспечения придуманы не только в Windows. Есть стандарты, например, для Linux - взять, хотя бы, включение номера версии в имя файла. Apple тоже не оставался в стороне - можно вспомнить структуру NumVersion. Какую систему для нумерации версий выбрать - вопрос личных предпочтений, благо, выбирать есть из чего, да и фантазию никто не отменял. Хотя, если вы придерживаетесь стандартных решений, свойственных той или иной ОС, это может наложить некоторые ограничения на выбираемую вами систему.

К чему это я все написал? Недавно передо мной встала задача анализа функционирования программы, написанной на Java. Всем известно, что Java изначально разрабатывалась, как кроссплатформенная система. Изначально - не значит, что от этой концепции кто-то отказался, совсем нет. Имеется в виду, что с самого начала делалось все для того, чтобы программы, написанные на Java, могли исполняться на любой платформе, для которой реализовывалась Java машина. Тут я позволю себе некоторое отступление.

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

По этому пути пошли, например, разработчики из Sun, стоявшие у истоков Java. И они не одиноки. Возьмем, ту же  компанию Embarcadero, владеющую сейчас и развивающую среду и язык программирования Delphi. Они обозначили свой вектор развития, выпустив сначала версию, позволяющую создавать приложения не только для Windows, но и для Mac OS X, затем для iOS, а теперь и для Android. Если вскользь взглянуть на то, как реализовано программирование пользовательского интерфейса в последних версиях их продукта, то становится понятным, что в Embarcadero решили отказаться от использования элементов GUI, реализованных в конкретных ОС. Все элементы - редакторы ввода, закладки, прокрутки, списки выбора и так далее и тому подобное - реализованы самостоятельно, на базе выкупленной когда-то (вместе с разработчиком) библиотеки, которая была модифицирована, развита и переименована в Firemonkey. Но от базовых сервисов ОС, позволяющих отрисовывать что-либо на экране устройства, отказываться, естественно, не стали (или не смогли).

При этом, есть и другой путь, который выбрала, например, компания, носящая название RemObjects. Чем примечательна сама компания и путь, который она выбрала? Компания эта производила продукт под названием Chrome, который был лицензирован небезызвестной нам компанией Embarcadero и распространялся под названием Prism. Этот продукт позволял на Delphi-подобном языке программирования писать софт для Microsoft .NET. И не только. Можно было писать для ASP.NET и даже для Mono. Сейчас, вроде, Embarcadero отказалось от дальнейшего развития этого направления, речь про поддержку .Net, и, соответственно, от этого продукта - речь про Prism. А вот RemObjects продолжил развитие, выпустив продукт под названием Oxygene. Основное рекламируемое отличие этого продукта от Delphi заключается в истинно родной поддержке всех платформ, поддерживаемых продуктом.

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

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

Ясно было, что эта ситуация требовала скорейшего исправления. И работа началась. Но прежде, чем описать пройденный путь, я расскажу о том, в каком окружении мне пришлось решать возникшую задачу. Итак, я писал на Java, а в качестве IDE использовал Netbeans версии 7.4.

Исходя из того, что я писал выше, должно быть понятно, что Java не будет использовать механизм для версионирования ПО, разрабатываемого с ее помощью, скажем, Windows, так как это решение не будет работать под Linux. Для указания информации о версии Java использует свой собственный подход. Этот подход заключается в следующем.

Программный код в Java структурируется: классы объединяются в специальные модули, называемые пакетами. Сами эти пакеты группируются в файлы, имеющие расширение jar. Внутри этих jar файлов может находиться необязательный файл с метаинформацией, описывающий как сам этот jar файл, так и то, как кто-либо может его использовать. Этот файл с метаинформацией носит название MANIFEST.MF и располагается в jar файле в подкаталоге META-INF; его называют файлом манифеста.

Именно в этом файле Java предполагает размещение информации о версии. Причем, информация в файле манифеста может группироваться, а информацию о версии можно поместить в каждую группу. Обычно группы, или секции, в файле манифеста соответствуют пакетам и/или классам, помещенным в jar файл, хотя это совсем необязательно. Кроме того, информация в файле манифеста, которая явно не включена в какую-либо секцию, считается принадлежащей к так называемой главной секции (это секция считается существующей всегда, хотя явно и не описывается). Так вот, можно в главную секцию jar файла поместить информацию о версии всего файла, информацию о версиях входящих в этот файл пакетов разместить в соответствующих секциях пакетов, можно даже указывать версии для отдельных классов, включая их в соответствующих секциях, хотя на практике так поступают редко.

Для указания версии Java предлагает использовать следующие стандартные свойства (иногда их называют заголовками): Specification-Title, Specification-Version, Specification-Vendor, Implementation-Title, Implementation-Version, Implementation-Vendor. Таким образом, Java предлагает развести версионирование спецификаций и их реализаций. Что ж, мысль интересная и, наверное, логически объяснимая. Но к ней надо привыкнуть. На самом деле, можно использовать и свои собственные свойства (заголовки), и многие этим пользуются. Так, в OSGi используются Bundle-Name, Bundle-Version и многие другие свойства. Главное условие использования своих собственных свойств - приложения, для которых эти свойства важны, должны сами их понимать.

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

Теперь коротенько про то, как Netbeans позволяет организовать работу с проектами. Проект, созданный в Netbeans, приведет к появлению jar файла. В проект могут быть включены несколько пакетов со своими файлами классов. Если разрабатываемое ПО должно состоять из нескольких jar файлов, например, несколько самописных библиотек и главное приложение, то проекты могут быть объединены в группу.

Под этой концепцией Netbeans прячет самые обычные скрипты сборки Ant. Ну ладно, не совсем обычные, они - разработчики Netbeans - придумали и реализовали свою структуру файлов и каталогов. Для каждого проекта заводится свой набор скриптов и вспомогательных файлов. В каталоге проекта размещается файл build.xml. Этот файл практически одинаков для любого проекта. Его основное предназначение - подключить другой скрипт сборки, который создается средой программирования в файле build-impl.xml в подкаталоге nbproject основного каталога проекта. Вот этот скрипт уже отличается от проекта к проекту, так как система генерирует его исходя из настроек, которые пользователь задает для проекта в процессе его создания или работы с ним. Причем, этот файл не предназначен для ручного редактирования, так как любое изменение свойств проекта в IDE может привести к перегенерации этого файла. При этом, естественно, все ручные правки будут потеряны. Для ручной модификации предназначен именно build.xml, и, для облегчения процесса приведения скрипта сборки в нужный для пользователя вид, скрипт в файле build-impl.xml предусматривает ряд пустых целей сборки, которые можно переопределять в скрипте build.xml. Конечно же, в build.xml можно переопределить и непустые цели скрипта build-impl.xml, пожалуйста, хоть все. Можно также вводить и собственные цели.

Кроме скриптов, среда Netbeans создает еще файл со свойствами, которые используются в этих самых скриптах - это файл project.properties в том же подкаталоге nbproject. Частично, свойства в этом файле можно задавать в IDE, но их можно редактировать и вручную - Netbeans предварительно считывает те свойства, которые позволяет редактировать в своем GUI. Кроме того, он не перегенирирует файл со свойствами заново - при сохранении свойств проекта в своих диалоговых окнах он записывает только измененные свойства. Таким образом, свойства, не имеющие элементов пользовательского интерфейса для взаимодействия с разработчиком, например, совершенно новые свойства, созданные программистом в этом файле для своих нужд, будут сохранять свои значения, заданные при ручном редактировании файла project.properties.

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

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

Изучение скрипта build-impl.xml дает некоторую пищу для размышлений: в скрипте проверяется существование на диске файла манифеста, имя которого задается при помощи свойства manifest.file в файле свойств проекта project.properties. Это свойство может быть задано следующим образом:
 manifest.file=manifest.mf  
А теперь еще немного результатов поверхностного изучения скрипта сборки build-impl.xml. Если вы укажете это свойство, скрипт проверит, существует ли такой файл в каталоге проекта (ну, или где вы его там указали). Если файл не существует, скрипт сам создаст временный файл, который будет использоваться в качестве рабочей копии для записи всевозможной информации. А вот если файл существует, то он будет скопирован для использования в качестве рабочей копии и скрипт именно в него будет дописывать разнообразную информацию. То есть, если надо поместить что-то в файл манифеста, надо создать этот файл, точнее, затравку для него, и заполнить нужной информацией. Но сделать это нужно до момента проверки существования файла скриптом.

Для решения этой задачи есть несколько путей. Один из них, что называется - в лоб: просто создаете файл манифеста любым текстовым редактором, записываете в него нужную информацию и сохраняете в каталоге проекта (ну, если только явно не указали в свойстве manifest.file какой-нибудь другой каталог). Это просто, но как-то... не по программистски, что ли. Настоящие программисты должны выбрать другой путь. И этот путь - автоматизация создания файла манифеста при помощи скрипта сборки.

У Ant есть специальная задача - manifest, которая и предназначена для создания файла манифеста, почитать про нее можно тут. У этой задачи есть все нужные нам возможности: можно указать имя файла манифеста, задать значения свойств (элементы <attribute/>), группировать свойства (элементы <section/>). Теперь остается только придумать, куда включить эту задачу. И это, вроде бы, не очень сложный вопрос. Но, как и всегда, засада всегда бывает там, где ее меньше всего ожидаешь.

Я уже упоминал, что у Netbeans есть специальный скрипт (build-impl.xml), который не позволено редактировать руками, и скрипт (build.xml), который нужно редактировать, когда появляются требования, не реализованные в автоматически сгенерированном скрипте. И более того, автоматически сгенерированный скрипт, для облегчения жизни, предлагает ряд пустых целей, которые можно переопределить в "ручном" скрипте. Одна из таких целей носит название -pre-init и вызывается она перед целью инициализации всевозможных параметров сборки. Это очень удобное место для решения нашей проблемы - именно в эту цель я и добавил задачу manifest. Вот, что я сделал:
 <manifest file="manifest.mf">  
   <attribute name="Implementation-Title" value="Super Program"/>  
   <attribute name="Implementation-Version" value="1.2.3"/>  
 </manifest>  
Но, мы ведь настоящие программисты и должны придерживаться принятых стандартов. Эти стандарты в нашем случае устанавливали разработчики Netbeans и они придумали все константы (ну, почти все) выносить в файл свойств project.properties, размещаемом в подкаталоге nbproject. Итак, надо поместить значения, используемые в качестве информации о версии, в виде свойств в файл свойств и сослаться на эти значения в нашем скрипте. В итоге, должно получиться что-то вроде такого:
файл свойств project.properties:
 manifest.file=manifest.mf  
 application.title=Super Program  
 application.version=1.2.3  
файл скрипта сборки build.xml:
 <target name="-pre-init">  
   <manifest file="${manifest.file}">  
     <attribute name="Implementation-Title" value="${application.title}"/>  
     <attribute name="Implementation-Version" value="${application.version}"/>  
   </manifest>  
 </target>  
Вот тут-то и оказалась засада. Дело в том, что скрипт построен таким образом, что цели, в которых загружаются файлы со всевозможными свойствами, в том числе и project.properties, выполняются позже цели -pre-init, и, поэтому, получается, что свойства, на которые я ссылаюсь при описании элементов <attribute/>, на момент выполнения моей переопределенной цели -pre-init, еще не проинициализированы. Поэтому файл создается, но имя у него странное, мягко говоря - ${manifest.file}. Да и внутри у него тоже нераскрытые ссылки на свойства, вместо их значений. Правда, Netbeans каким-то волшебным образом рядом создает нормальный файл манифеста с совершенно верной информацией. Как он это делает? Я не стал глубоко копать, а при поверхностном изучении ответ звучит так: это чудо!

Но такое положение дел меня не очень устроило. Согласитесь, пользоваться тем, что не очень понимаешь, не есть хороший тон. Да и кроме того, сегодня чудо есть, а завтра начнутся будни, и что прикажете тогда делать? В общем, я решил поискать другой путь. Правда, пришлось повнимательнее присмотреться к скрипту build-impl.xml, но это полезно - пользуясь чем-либо, что облегчает тебе жизнь, неплохо иметь представление о том, какие действия выполняются за кулисами и как вообще это все работает. Только тогда можно опираться на этот облегчитель жизни, иначе можно получить множество проблем. Это моя точка зрения, и да, я понимаю, что все знать нельзя.

Так вот, поизучав скрипт, я пришел к выводу, что очень не сложно будет реализовать такой механизм: дать отработать стандартной инициализационной части скрипта сборки, никаких переопределенных -pre-init целей. А вот после того, как инициализация произведена, вклиниться в процесс, благо, для этого есть -post-init. Я решил реализовать следующий алгоритм этой пост-инициализационной обработки. Во-первых, выполнять эту цель будем только в случае, если нет созданного вручную файла манифеста; предполагаем, что если он есть, то в него уже заложили всю необходимую информацию. Во-вторых, раз цель работает только при отсутствии файла манифеста, то создадим этот файл, вернее, затравку для него, при помощи задачи <manifest/>. Ну и в-третьих, после создания манифеста, выставим пару рабочих свойств скрипта build-impl.xml, которые активно используются в дальнейшем при сборке jar файла. Если этого не сделать, свойства эти будут установлены (вернее, не установлены) так, как-будто файла манифеста нет, и скрипт сгенерирует стандартный манифест, без нашей информации. Итак, получаем:
 <target name="-post-init" unless="manifest.available">  
   <manifest file="${manifest.file}">  
     <attribute name="Implementation-Title" value="${application.title}"/>  
     <attribute name="Implementation-Version" value="${application.version}"/>  
   </manifest>  
   <available file="${manifest.file}" property="manifest.available"/>  
   <condition property="do.archive+manifest.available">  
     <and>  
       <isset property="manifest.available"/>  
       <istrue value="${do.archive}"/>  
     </and>  
   </condition>  
 </target>  
Тут тоже есть небольшая засада: в будущем скрипты сборки Netbeans могут измениться, свойства могут иметь другие названия или вовсе исчезнуть - тогда алгоритм перестанет работать. Что ж, когда изменятся, тогда и будем думать.

Остается еще один маленький момент. Он будет характеризовать нас, как очень аккуратных настоящих программистов. Мы написали задачу в скрипте сборки для создания файла манифеста. Когда скрипт сам создает временный файл манифеста, он использует специальный атрибут, значение которого установлено таким образом, чтобы временный файл удалялся после использования. То есть, скрипт подчищает за собой. А чем мы хуже, мы тоже хотим убрать за собой мусор.

Для удаления файлов есть специальная задача Ant <delete/>. У этой задачи есть атрибут file, позволяющий задать имя удаляемого файла, ну и, конечно же, мы можем использовать ссылку на свойство из файла project.properties в качестве значения этого атрибута. Когда же будет безопасно для нас удалить созданную нами затравку для файла манифеста? Ответ довольно прост. В скрипте сборки есть специальная цель, предназначенная для создания jar файла и включения в него всего, что необходимо. Следовательно, как только jar файл будет сгенерирован, наша затравка файла манифеста, очевидно, станет не нужна. И снова нам на помощь приходят разработчики скрипта сборки build-impl.xml - они предусмотрели пустую цель -post-jar, которая должна выполняться после генерации jar файла. Вот эту цель мы и переопределим в нашем многострадальном скрипте build.xml:
 <target name="-post-jar">  
   <delete file="${manifest.file}"/>  
 </target>  
Осуществив теперь сборку проекта, можно открыть получившийся jar файл, войти в подкаталог META-INF и изучить содержимое файла MANIFEST.MF:
 Manifest-Version: 1.0  
 Ant-Version: 1.9.1  
 Created-By: 1.7.0_51-b13 (Oracle Corporation)  
 Implementation-Title: Super Program  
 Implementation-Version: 1.2.3  
Что-то я был слишком оптимистичен по поводу "остается один маленький момент". Есть еще одна тема, которую надо хоть немного затронуть. Мы ведь не хотим каждый раз, когда надо проверить версию ПО, вручную открывать jar файл и смотреть содержимое файла манифеста? Для работы с файлом манифеста у Java предусмотрены все необходимые инструменты. Я сейчас про программное взаимодействие с файлом манифеста, размещенным в jar файле. Для этого в пакете java.util.jar предусмотрен целый класс Manifest. Именно его я и использовал для получения информации о версии в своей программе. Вот упрощенный отрывок этого кода:
 private void showImplementationVersion() {  
   InputStream mfStream = getClass().getClassLoader().  
                   getResourceAsStream("META-INF/MANIFEST.MF");  
   try {  
     Manifest mf = new Manifest();  
     try {  
       mf.read(mfStream);  
     } catch (IOException e) {  
       e.printStackTrace();  
     }  
   }  
   finally {  
     mfStream.close();  
   }  
   Attributes atts = mf.getMainAttributes();  
   System.out.println("version: " + atts.getValue("Implementation-Version"));  
 }  
Сразу предупрежу: код, который работает в моей программе, несколько отличается от приведенного выше, но тут, скорее, важна идея. А она заключается в следующем: файл манифеста в jar файле мы рассматриваем как обычный ресурс. Зная имя ресурса, можно получить InputStream и использовать его для инициализации экземпляра класса Manifest (кстати, это можно сделать прямо в конструкторе: mf = new Manifest(mfStream);). Так как мне необходимо получить информацию о версии, помещенную в главную секцию манифеста, то я считываю все атрибуты этой секции, а потом получаю значение нужного мне атрибута по имени. Если бы мне надо было бы получить версию для какого-либо пакета (при условии, что информация о версии пакета была бы добавлена в файл манифеста), размещенного в jar файле, следовало бы воспользоваться методом getAttributes(String name), а в качестве аргумента передать путь к пакету в этом jar файле  (имеется в виду, что указывать полное имя пакета надо используя символ '/', а не '.').

Если надо получить доступ к манифесту другого jar файла, что ж, нет ничего невозможного. Вот пример, как это можно сделать. Вообще-то, в Java существует специальный класс, который позволяет работать с информацией о версии пакета - это класс java.lang Package. Но с помощью него можно получить информацию только о версии пакета, то есть, только информацию о версии, которая включена в какую-либо секцию манифеста, описывающую пакет. Таким образом, получить информацию о версии, включенную в главную секцию файла манифеста, согласно документации, нельзя. Вообще говоря, это косвенным образом намекает, что информация о версии должна быть привязана к пакетам. Хотя, с другой стороны, класс называется Package, так почему он должен работать с чем-то другим? Кстати, хороший пример работы с этим классом можно найти тут.

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

Комментариев нет:

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