Данная статья предназначена для людей, которые никогда не работали с Аннотациями, но хотели бы разобраться, что это и с чем его едят. Если же вы имеете опыт в данной сфере, не думаю, что эта статья как-то расширит ваши знания (да и, собственно, такую цель я не преследую).
Также статья не подходит для тех, кто только начинает изучать язык Java. Если Вы не понимаете что такое Map<> или HashMap<> или не знаете что означает запись static{ } внутри определения класса, либо же никогда не работали с рефлексией – Вам рано читать эту статью и пытаться понять, что такое аннотации. Сам по себе этот инструмент не создан для использования новичками, так как требует уже не совсем базовых пониманий взаимодействия классов и объектов (моё мнение) (спасибо комментариям за то, что показали необходимость этой приписки).
Итак, приступим.
Аннотации в Java являются своего рода метками в коде, описывающими метаданные для функции/класса/пакета. Например, всем известная Аннотация @Override, обозначающая, что мы собираемся переопределить метод родительского класса. Да, с одной стороны, можно и без неё, но если у родителей не окажется этого метода, существует вероятность, что мы зря писали код, т.к. конкретно этот метод может и не вызваться никогда, а с Аннотацией @Override компилятор нам скажет, что: "Я не нашел такого метода в родителях... что-то здесь нечисто".
Однако Аннотации могут нести в себе не только смысл "для надежности": в них можно хранить какие-то данные, которые после будут использоваться.
Для начала рассмотрим простейшие аннотации предоставляемые стандартной библиотекой.
(снова же спасибо комментариям, вначале не подумал что этот блок нужен) Сначала обсудим, какие бывают аннотации. Каждая из них имеет 2 главных обязательных параметра:- Тип хранения (Retention);
- Тип объекта над которым она указывается (Target).
Тип хранения
Под "типом хранения" понимается стадия до которой "доживает" наша аннотация внутри класса. Каждая аннотация имеет только один из возможных "типов хранения" указанный в классе RetentionPolicy:- SOURCE - аннотация используется только при написании кода и игнорируется компилятором (т.е. не сохраняется после компиляции). Обычно используется для каких-либо препроцессоров (условно), либо указаний компилятору
- CLASS - аннотация сохраняется после компиляции, однако игнорируется JVM (т.е. не может быть использована во время выполнения). Обычно используется для каких-либо сторонних сервисов, подгружающих ваш код в качестве plug-in приложения
- RUNTIME - аннотация которая сохраняется после компиляции и подгружается JVM (т.е. может использоваться во время выполнения самой программы). Используется в качестве меток в коде, которые напрямую влияют на ход выполнения программы (пример будет рассмотрен в данной статье)
Тип объекта над которым указывается
Данное описание стоит понимать практически буквально, т.к. в Java аннотации могут указываться над чем угодно (Поля, классы, функции, т.д.) и для каждой аннотации указывается, над чем конкретно она может быть задана. Здесь уже нет правила "что-то одно", аннотацию можно указывать над всем ниже перечисленным, либо же выбрать только нужные элементы класса ElementType:- ANNOTATION_TYPE - другая аннотация
- CONSTRUCTOR - конструктор класса
- FIELD - поле класса
- LOCAL_VARIABLE - локальная переменная
- METHOD - метод класса
- PACKAGE - описание пакета package
- PARAMETER - параметр метода public void hello(@Annontation String param){}
- TYPE - указывается над классом
@Override
Retention: SOURCE; Target: METHOD. Данная аннотация показывает, что метод над котором она прописана наследован у родительского класса. Первая аннотация с которой сталкивался каждый начинающий Java-программист, при использовании IDE, которая настойчиво пихает эти @Override. Зачастую учителя с ютуба рекомендуют либо: "сотрите чтобы не мешало", либо: "оставьте не задумываясь зачем оно здесь". На самом деле аннотация более чем полезна: она не только позволяет понять какие методы были определены в этом классе впервые, а какие уже есть у родителей (что бесспорно повышает читаемость вашего кода), но также данная аннотация служит "самопроверкой", что вы не ошиблись при определении перегружаемой функции.@Deprecated
Retention: Runtime; Target: CONSTRUCTOR, FIELD, LOCAL_VARIABLE, METHOD, PACKAGE, PARAMETER, TYPE. Данная аннотация указывает на методы, классы или переменные, которые является "устаревшими" и могут быть убраны в последующих версиях продукта. С данной аннотацией обычно сталкиваются те, кто читает документацию каких-либо API, либо той же стандартной библиотеки Java. Иногда эту аннотацию игнорируют, т.к. она не вызывает никаких ошибок и в принципе сама по себе сильно жить не мешает. Однако главный посыл, который несет в себе данная аннотация – "мы придумали более удобный метод реализации данного функционала, используй его, не используй старый" - ну, либо же - "мы переименовали функцию, а это так, для легаси оставили..." (что тоже в общем-то неплохо). Короче говоря, если видите @Deprecated - лучше стараться не использовать то, над чем она висит, если в этом нет прям крайней необходимости и, возможно, стоит перечитать документацию, чтобы понять каким образом теперь реализуется задача, выполняемая устаревшим элементом. Например вместо использований new Date().getYear() рекомендуется использовать Calendar.getInstance().get(Calendar.YEAR).@SuppressWarnings
Retention: SOURCE; Target: TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE Данная аннотация отключает вывод предупреждений компилятора, которые касаются элемента над которым она указана. Является SOURCE аннотацией указываемой над полями, методами, классами.@Retention
Retention: RUNTIME; Target: ANNOTATION_TYPE; Данная аннотация задает "тип хранения" аннотации над которой она указана. Да эта аннотация используется даже для самой себя... магия да и только.@Target
Retention: RUNTIME; Target: ANNOTATION_TYPE; Данная аннотация задает тип объекта над которым может указываться создаваемая нами аннотация. Да и она тоже используется для себя же, привыкайте... Думаю, на этом можно завершить ознакомление со стандартными аннотациями библиотеки Java, т.к. остальные используются достаточно редко и, хоть и несут свою пользу, сталкиваться с ними приходится не всем и совершенно необязательно. Если же вы хотите чтобы я рассказал о какой-то конкретной аннотации из стандартной библиотеки (либо, возможно, аннотации типа @NotNull и @Nullable которые в STL не входят) напишите в комментариях - либо вам там ответят добрые пользователи, либо я когда увижу. Если уж много людей будут просить какую-то аннотацию - также внесу её в статью.Практическое применение RUNTIME аннотаций
Собственно, думаю, хватит теоретической болтавни: давайте перейдем к практике на примере бота. Допустим вы хотите написать бота для какой-то соцсети. У всех крупных сетей, таких как ВК, Facebook, Discord, есть свои API, которые позволяют написать бота. Для этих же сетей есть уже написанные библиотеки для работы с API, на языке Java в том числе. Поэтому не будем углубляться в работу какого-либо API или библиотеки. Всё, что нам нужно знать в данном примере — то, что наш бот умеет реагировать на сообщения, отправленные в чат, в котором, собственно, наш бот находится. Т.е допустим, у нас есть класс MessageListener с функцией:
public class MessageListener
{
public void onMessageReceived(MessageReceivedEvent event)
{
}
}
Она отвечает за обработку принятого сообщения. Всё что нам нужно от класса MessageReceivedEvent — строка полученного сообщения (например, "Привет" или "Бот, привет"). Стоит учесть: в разных библиотеках эти классы называются по-разному. Я использовал библиотеку для Discord.
И вот мы хотим сделать так, чтобы бот реагировал на какие-то команды, начинающиеся с "Бот" (с запятой или без — решайте сами: для урока предположим, что запятой там быть не должно).
То есть, уже наша функция будет начинаться с чего-то вроде:
public void onMessageReceived(MessageReceivedEvent event)
{
//Убираем чувствительность к регистру (БоТ, бОт и т.д.)
String message = event.getMessage().toLowerCase();
if (message.startsWith("бот"))
{
}
}
И вот теперь перед нами есть множество вариантов реализации той или иной команды. Бесспорно, для начала нужно отделить команду от её аргументов, т.е разбить на массив.
public void onMessageReceived(MessageReceivedEvent event)
{
//Убираем чувствительность к регистру (БоТ, бОт и т.д.)
String message = event.getMessage().toLowerCase();
if (message.startsWith("бот"))
{
try
{
//получим массив {"Бот", "(команду)", "аргумент1", "аргумент2",... "аргументN"};
String[] args = message.split(" ");
//Для удобства уберем "бот" и отделим команду от аргументов
String command = args[1];
String[] nArgs = Arrays.copyOfRange(args, 2, args.length);
//Получили command = "(команда)"; nArgs = {"аргумент1", "аргумент2",..."аргументN"};
//Данный массив может быть пустым
}
catch (ArrayIndexOutOfBoundsException e)
{
//Вывод списка команд или какого-либо сообщения
//В случае если просто написать "Бот"
}
}
}
Данного куска кода нам никак не избежать, потому что отделение команды от аргументов нужно всегда. А вот дальше уже у нас есть выбор:
- Сделать if(command.equalsIngnoreCase("..."))
- Сделать switch(command)
- Сделать ещё какой-то способ обработки...
- Либо же прибегнуть к помощи Аннотаций.
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
//Указывает, что наша Аннотация может быть использована
//Во время выполнения через Reflection (нам как раз это нужно).
@Retention(RetentionPolicy.RUNTIME)
//Указывает, что целью нашей Аннотации является метод
//Не класс, не переменная, не поле, а именно метод.
@Target(ElementType.METHOD)
public @interface Command //Описание. Заметим, что перед interface стоит @;
{
//Команда за которую будет отвечать функция (например "привет");
String name();
//Аргументы команды, использоваться будут для вывода списка команд
String args();
//Минимальное количество аргументов, сразу присвоили 0 (логично)
int minArgs() default 0;
//Описание, тоже для списка
String desc();
//Максимальное число аргументов. В целом не обязательно, но тоже можно использовать
int maxArgs() default Integer.MAX_VALUE;
//Показывать ли команду в списке (вовсе необязательная строка, но мало ли, пригодится!)
boolean showInHelp() default true;
//Какие команды будут считаться эквивалентными нашей
//(Например для "привет", это может быть "Здаров", "Прив" и т.д.)
//Под каждый случай заводить функцию - не рационально
String[] aliases();
}
Важно! Каждый параметр описывается как функция (с круглыми скобками). В качестве параметров могут быть использованы только примитивы, String, Enum. Нельзя написать List<String> args(); — ошибка.
Теперь, когда мы описали Аннотацию, давайте заведем класс, назовем его CommandListener.
public class CommandListener
{
@Command(name = "привет",
args = "",
desc = "Будь культурным, поздоровайся",
showInHelp = false,
aliases = {"здаров"})
public void hello(String[] args)
{
//Какой-то функционал, на Ваше усмотрение.
}
@Command(name = "пока",
args = "",
desc = "",
aliases = {"удачи"})
public void bye(String[] args)
{
// Функционал
}
@Command(name = "помощь",
args = "",
desc = "Выводит список команд",
aliases = {"help", "команды"})
public void help(String[] args)
{
StringBuilder sb = new StringBuilder("Список команд: \n");
for (Method m : this.getClass().getDeclaredMethods())
{
if (m.isAnnotationPresent(Command.class))
{
Command com = m.getAnnotation(Command.class);
if (com.showInHelp()) //Если нужно показывать команду в списке.
{
sb.append("Бот, ")
.append(com.name()).append(" ")
.append(com.args()).append(" - ")
.append(com.desc()).append("\n");
}
}
}
//Отправка sb.toString();
}
}
Стоит отметить одно небольшое неудобство: т.к. мы сейчас боремся за универсальность, все функции должны иметь одинаковый список формальных параметров, поэтому даже если у команды нет аргументов, у функции должен быть параметр String[] args.
Мы сейчас описали 3 команды: привет, пока, помощь.
Теперь давайте модифицируем наш MessageListener так, чтобы он как-то с этим работал. Для удобства и скорости работы, будем сразу хранить наши команды в HashMap:
public class MessageListner
{
//Map который хранит как ключ команду
//А как значение функцию которая будет обрабатывать команду
private static final Map<String, Method> COMMANDS = new HashMap<>();
//Объект класса с командами (по сути нужен нам для рефлексии)
private static final CommandListener LISTENER = new CommandListener();
static
{
//Берем список всех методов в классе CommandListener
for (Method m : LISTENER.getClass().getDeclaredMethods())
{
//Смотрим, есть ли у метода нужная нам Аннотация @Command
if (m.isAnnotationPresent(Command.class))
{
//Берем объект нашей Аннотации
Command cmd = m.getAnnotation(Command.class);
//Кладем в качестве ключа нашей карты параметр name()
//Определенный у нашей аннотации,
//m — переменная, хранящая наш метод
COMMANDS.put(cmd.name(), m);
//Также заносим каждый элемент aliases
//Как ключ указывающий на тот же самый метод.
for (String s : cmd.aliases())
{
COMMANDS.put(s, m);
}
}
}
}
public void onMessageReceived(MessageReceivedEvent event)
{
String message = event.getMessage().toLowerCase();
if (message.startsWith("бот"))
{
try
{
String[] args = message.split(" ");
String command = args[1];
String[] nArgs = Arrays.copyOfRange(args, 2, args.length);
Method m = COMMANDS.get(command);
if (m == null)
{
//(вывод помощи)
return;
}
Command com = m.getAnnotation(Command.class);
if (nArgs.length < com.minArgs())
{
//что-то если аргументов меньше чем нужно
}
else if (nArgs.length > com.maxArgs())
{
//что-то если аргументов больше чем нужно
}
//Через рефлексию вызываем нашу функцию-обработчик
//Именно потому что мы всегда передаем nArgs у функции должен быть параметр
//String[] args — иначе она просто не будет найдена;
m.invoke(LISTENER, nArgs);
}
catch (ArrayIndexOutOfBoundsException e)
{
//Вывод списка команд или какого-либо сообщения
//В случае если просто написать "Бот"
}
}
}
}
Вот собственно и всё, что нужно, чтобы наши команды работали. Теперь добавление новой команды — это не новый if, не новый case, в которых нужно было бы заново переучесть количество аргументов, также пришлось бы переписывать help, добавляя в него новые строки. Теперь же, чтобы добавить команду, нам нужно просто в классе CommandListener добавить новую функцию с аннотацией @Command и всё — команда добавлена, случаи учтены, help дополнен автоматически.
Абсолютно бесспорно, что данную задачу можно решить множеством других путей. Да, всё что можно сделать при помощи аннотаций/рефлексий можно сделать и без них, вопрос лишь в удобстве, оптимальности и размерах кода, конечно же, совать Аннотацию везде где есть малейший намек на то, что получится её использовать - тоже не самый рациональный вариант, во всем нужно знать меру =). Но при написании API, Библиотек или программ, в которых возможно повторение однотипного (но не совсем одинакового) кода, аннотации - бесспорно оптимальное решение.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ